PROD DEPLOY 09/08/2026 — cost wave 1 + python-math stack (engine unchanged) - #2685
Merged
Conversation
#2597) * refactor(delphi): delete dead scalar paths in repness.py (PR 14a) The scalar implementations in `delphi/polismath/pca_kmeans_rep/repness.py` were test-only — production calls only `compute_group_comment_stats_df`, `select_rep_comments_df`, and `select_consensus_comments_df` (via `conv_repness`). Maintaining two parallel implementations created "where do I put this helper?" ambiguity for the upcoming D10/D11/D12 fixes (all of which add new helpers to `repness.py`) and obscured the structure of the production path. Foundation pass before D10/D11/D12 + PR 14b/14c. No behavioural change — production path untouched. ## Production code (`delphi/polismath/pca_kmeans_rep/repness.py`) DELETED (445 lines): - Primitives: `prop_test`, `two_prop_test` (no production callers). - Orchestration: `comment_stats`, `add_comparative_stats`, `repness_metric`, `finalize_cmt_stats`, `passes_by_test`, `best_agree`, `best_disagree`, `select_rep_comments`, `select_consensus_comments`. - Unused helper: `calculate_kl_divergence` (no callers anywhere in repo). KEPT: - `z_score_sig_90`, `z_score_sig_95` — trivial threshold checks consumed scalar-side; vectorizing would not save lines. ENRICHED: - `prop_test_vectorized` and `two_prop_test_vectorized` docstrings now embed the scalar-equivalent closed-form algebra. The formulas stay readable even though the scalar functions are gone. ## Tests DELETED entirely: - `tests/test_old_format_repness.py` (557 lines, scalar-only sibling of `test_repness_unit.py`). DELETED classes/methods in `tests/test_repness_unit.py`: - `TestCommentStats`, `TestSelectionFunctions`, `TestConsensusAndGroupRepness` (all scalar-only). - `TestStatisticalFunctions::test_prop_test`, `::test_two_prop_test`. MIGRATED to single vectorized DataFrame calls in `tests/test_discrepancy_fixes.py`: - D4/D5/D6 BlobInjection classes — build a DataFrame from the blob's `repness` entries, run one `prop_test_vectorized` / `two_prop_test_vectorized` call, compare element-wise. Tests the actual production code path, produces cleaner diagnostics via `.to_string()`. - `TestD5ProportionTest::test_prop_test_matches_clojure_formula` (consolidated with the n=0 boundary case). - `TestD6TwoPropTest::test_two_prop_test_matches_clojure_formula` (+ edge cases consolidated, + pi_hat=1 boundary cases). CONSOLIDATED in `tests/test_discrepancy_fixes.py`: - `TestD8FinalizeStats`'s 7 scalar boundary tests collapse into a single parametrized DataFrame test `test_repful_classification_boundary` that exercises the production `np.where(rat > rdt, 'agree', 'disagree')` logic. All boundary cases preserved (rat<rdt, rat>rdt, rat==rdt non-zero, rat==rdt==0, negative z-scores). DELETED redundant tests: - `TestSyntheticEdgeCases::test_prop_test_matches_clojure_formula_synthetic` (duplicated by migrated TestD5ProportionTest). - `TestSyntheticEdgeCases::test_clojure_repness_metric_product` (duplicated by migrated TestD7RepnessMetric). - `TestSyntheticEdgeCases::test_clojure_repful_uses_rat_vs_rdt` (purely tautological). Cross-checks in `tests/test_repness_unit.py::TestVectorizedFunctions` now use `_prop_test_reference` and `_two_prop_test_reference` closed-form staticmethods in place of scalar calls. ## Suite delta - Pre (edge @ 2dce7385f): 330 passed, 12 skipped, 58 xfailed. - Post: 295 passed, 12 skipped, 58 xfailed. - Delta: -35 passed, 0 failed, 0 new xfailed. Matches deleted scalar-test count exactly. ## For PR 14c (readability refactor — runs later) The deleted scalar code is the readability reference for PR 14c. Retrieve via: git show <this-commit>~1:delphi/polismath/pca_kmeans_rep/repness.py \ | sed -n '161,302p' Specifically (pre-deletion line numbers): `comment_stats` 161-201, `add_comparative_stats` 203-235, `repness_metric` 237-271, `finalize_cmt_stats` 273-301. Clojure originals at `math/src/polismath/math/repness.clj:78-100,173-188,191-200`. ## Documentation - `delphi/docs/PLAN_DISCREPANCY_FIXES.md`: added PR 14a row to the stack cross-reference table. - `delphi/docs/CLJ-PARITY-FIXES-JOURNAL.md`: appended "Session: PR 14a — Scalar deletion (2026-06-11)" entry with the full scope, suite delta, and the `git show` recipe for PR 14c. ## Out of scope (handed off separately) - Pyright pandas-stubs noise (10+ false positives on `pd.DataFrame(columns=...)` and `df['col'] = value` in `compute_group_comment_stats_df`) is pre-existing on edge HEAD; PR #2560's pyright config didn't set rule overrides. Handoff: `~/polis/HANDOFF_PYRIGHT_PANDAS_STUBS.md`. Tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> commit-id:694a6768 * fix(delphi): ns includes PASS votes (Clojure parity, pre-D10) Bug --- `compute_group_comment_stats_df` in `delphi/polismath/pca_kmeans_rep/repness.py` computed `ns = na + nd` and `total_votes = total_agree + total_disagree`, silently dropping PASS (0) votes from every vote count. Clojure reference: `math/src/polismath/math/repness.clj:56-61, :70`: (defn- count-votes [votes & [vote]] (let [filt-fn (if vote #(= vote %) identity)] (count (filter filt-fn votes)))) ... :ns (fnk [votes] (count-votes votes)) `count-votes` with no `vote` argument uses `identity` as the filter predicate. In Clojure 0 is truthy, so `(filter identity ...)` keeps every non-nil entry — including PASS. Therefore Clojure `ns = na + nd + np` (PASS count). Every downstream metric that consumes `ns` — `pa`, `pd`, `pat`, `pdt`, `ra`, `rd`, `rat`, `rdt`, `agree_metric`, `disagree_metric`, and the upcoming D11 `consensus_stats_df` — was off whenever PASS votes existed. Fix --- Both `total_counts` and `group_counts` now compute the count column via `('vote', 'size')` directly in the pandas groupby aggregation. The frame is `dropna(subset=['vote'])`-filtered upstream, so `size()` counts exactly the non-NaN rows — including PASS (0). This is the Clojure `(count (filter identity votes))` recipe verbatim. Both sites carry a comment citing the Clojure reference. Why D5 BlobInjection didn't catch this -------------------------------------- D5's blob-injection tests pull `(n-success, n-trials)` directly from the Clojure blob's `repness` entries and feed them to `prop_test_vectorized`. They bypass `compute_group_comment_stats_df` entirely, so anything downstream of `ns = na + nd` was invisible. The same gap will recur for every fix whose stage-inputs are built by Python code. Pure-formula tests over a tiny vote matrix are the only RED path. Tests added ----------- `TestNsIncludesPassVotes` in `tests/test_repness_unit.py`: - `test_ns_includes_pass_votes` — single comment, 2 agree / 1 disagree / 2 pass → `na=2, nd=1, ns=5`. - `test_ns_all_pass_column` — all-PASS column → `na=0, nd=0, ns=3`. - `test_ns_mixed_with_nan_only_explicit_votes_count` — NaN never counts; PASS always does. - `test_other_votes_includes_other_group_pass` — `other_votes` (the complement of group `ns`) also includes out-of-group PASS. Suite delta ----------- Pre: 295 passed, 12 skipped, 58 xfailed (PR 14a baseline). Post: 299 passed, 12 skipped, 58 xfailed. Delta = +4 (the new tests). No pre-existing test broke — the existing `TestVectorizedFunctions` fixtures used only AGREE/DISAGREE/NaN and were never sensitive. Cascade to D11 -------------- D11's `consensus_stats_df(vote_matrix_df)` (whole-conversation counterpart) will mirror the same recipe at the conversation level. This fix lands BEFORE D10 in the stack so D11's implementation, when it arrives, starts from the corrected ns semantics. D11 should follow the same pure-formula test pattern. Goldens ------- DEFERRED. Re-recording is gated on sklearn-KMeans-seeding consensus; no golden values shift at the goldens commit until D10/D11/D12 land. commit-id:0761e6c3 * feat(delphi): D10 — Clojure-parity rep comment selection (PR 8) Replaces the pre-D10 botched-port `select_rep_comments_df` with a single-pass reduce that mirrors Clojure `select-rep-comments` (math/src/polismath/math/repness.clj:212-281). Sits on top of PR 14a in the spr stack. ## Helpers added (top-level in `repness.py`) - `passes_by_test(s)` — Clojure `passes-by-test?` (repness.clj:165-170). OR'd on (rat, pat) and (rdt, pdt) z-sig-90. NO `pa >= 0.5` gate; the pre-D10 Python gate was an over-restriction with no Clojure analog. - `beats_best_by_test(s, current_best_z)` — Clojure `beats-best-by-test?` (repness.clj:133-139). Strict `>` on `max(rat, rdt)`. - `beats_best_agr(s, current_best)` — Clojure `beats-best-agr?` (repness.clj:142-162). Four-branch agree-priority logic: 1. na == 0 AND nd == 0 → reject. 2. current_best AND current_best.ra > 1.0 → compare 4-way signed product `ra * rat * pa * pat`. 3. current_best (else, ra <= 1.0) → compare `pa * pat` only. 4. No current_best → accept if `z90(pat)` OR `(ra > 1.0 AND pa > 0.5)`. `current_best` stores the RAW row (Clojure repness.clj:250) so the ra/rat/pa/pat surface stays available across iterations. - `_finalize_row_for_output(row, *, is_best_agree=False)` — Clojure `finalize-cmt-stats` (repness.clj:173-188) + best-agree flagging (repness.clj:262-264). Emits `best_agree=True` and `n_agree=na` for the best-agree slot. ## `select_rep_comments_df` rewrite Signature now: `(stats_df, mod_out=None) -> List[Dict[str, Any]]`. Drops the `agree_count` / `disagree_count` kwargs (Clojure has only a cap of 5). Per-row state `{sufficient, best, best_agree}` updated by the helpers. Final assembly: dedup best_agree from sufficient → sort by metric (agree_metric for repful=='agree', disagree_metric for 'disagree') → prepend finalized+flagged best_agree → take 5 → agrees-before-disagrees. The caller in `conv_repness` drops the `_stats_row_to_dict` wrapping step (the new function returns finalized dicts directly). ## Two pre-D10 bugs fixed alongside the rewrite - `pa >= 0.5 / pd >= 0.5` over-gate in the passing filter — removed (no Clojure analog). - "Fill from other category" + "first row" fallback blocks — deleted. The `:best` / `:best_agree` mechanism IS the Clojure fallback. ## Tests (18 new in `tests/test_discrepancy_fixes.py`) - TestD10PassesByTest (4): agree-side, disagree-side, neither, no pa-gate. - TestD10BeatsBestByTest (3): None-best, max(rat,rdt), strict `>`. - TestD10BeatsBestAgr (6): one per Clojure branch + boundary. - TestD10SelectRepCommentsBoundary (5): empty input, single unvoted row → best fallback, sufficient-empty-best-agree-only, take-5 cap + agrees-before-disagrees, **the eviction edge case** (best_agree outside sufficient evicting 5th-highest-metric). ## Eviction edge case — flagged `take(5)` runs AFTER prepending best_agree. If `best_agree` was kept by `beats_best_agr` as a non-significant agree-priority fallback (failed `passes_by_test`, qualified via Branch 4) AND `:sufficient` already has 5 entries, the prepend pushes total to 6 and `take(5)` evicts the 5th-highest-metric sufficient entry — possibly a strong dissenting view. Mirrors Clojure exactly for blob parity. `# TODO(parity-eviction)` comment at the take(5) site in the production code; entry added under "Pending — needs team discussion" in PLAN.md; pinned by the synthetic test above. ## Re-xfailed with updated reasons (D14 / D1 upstream divergence) Six per-shared-(gid, tid) blob-comparison tests were previously xfailed as "D5/D6/D7/D8/D10: no shared comments to compare". After D10 there ARE shared comments (overlap ~20% on vw cold_start), but the per-(gid, tid) stats still mismatch because Python and Clojure place different participants in the "same" group ID. That's upstream PCA/KMeans group-membership divergence (D14 / D1), not D10. Updated xfail reasons point at D14 / D1. D10 itself is verified by the 18 synthetic tests. Affected: TestD9ZScoreThresholds::test_z_values_match_clojure, TestD5ProportionTest::test_pat_values_match_clojure_blob, TestD6TwoPropTest::test_rat_values_match_clojure_blob, TestD7RepnessMetric::test_repness_metric_matches_clojure_blob, TestD8FinalizeStats::test_repful_matches_clojure_blob, TestD10RepCommentSelection::test_rep_comments_match_clojure. ## Suite delta - Pre (post-14a baseline): 295 passed, 12 skipped, 58 xfailed. - Post: 313 passed, 12 skipped, 58 xfailed. - Delta: +18 passed (the 18 new D10 synthetic tests), 0 failed, no new xfailed. ## Documentation - `delphi/docs/PLAN_DISCREPANCY_FIXES.md`: PR 14a row marked landed (#2564), PR 8 (D10) marked in-flight, eviction concern added to "Pending — needs team discussion". - `delphi/docs/CLJ-PARITY-FIXES-JOURNAL.md`: "Session: PR 8 — D10 rep comment selection (2026-06-11)" entry with full scope, suite delta, and decisions log pointer. ## /goal mode This PR is part of an autonomous run (`/goal`) targeting D10 + D11 + D12 + golden snapshots as a stacked PR series. Decisions made autonomously (key naming, return type, etc.) are documented in `~/polis/D10_D11_D12_GOLDENS_DECISIONS.md` for batch user review at the end of the run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> commit-id:1b681fef * feat(delphi): D11 — Clojure-parity consensus comment selection (PR 9) Replaces the pre-D11 consensus logic (per-group `pa > 0.6 for all` filter, top 2 by `avg_pa`) with a whole-conversation per-comment-stats stage and two independent top-5 lists (agree / disagree), matching Clojure `consensus-stats` + `select-consensus-comments` (math/src/polismath/math/repness.clj:284-323). Sits on top of PR 8 (D10) in the spr stack. ## Production changes (`repness.py`) - **`consensus_stats_df(vote_matrix_df, mod_out=None) -> pd.DataFrame`**: new helper. Whole-conversation per-comment stats (no group split). Output DataFrame indexed by tid with cols [na, nd, ns, pa, pd, pat, pdt]. Vectorized port of Clojure `consensus-stats` (repness.clj:284-290). **`ns` includes PASS** (Clojure parity, repness.clj:56-61): computed as `vote_matrix_df.notna().sum(axis=0)` rather than `na + nd`. Matches Clojure `(count (filter identity ...))` where `0` (PASS) is truthy. - **`select_consensus_comments_df` rewrite**: new signature `(cons_stats) -> Dict[str, List[Dict]]`. Filters and ordering: - agree: `pa > 0.5 AND z-sig-90(pat)`, sorted desc by `am = pa * pat`. - disagree: `pd > 0.5 AND z-sig-90(pdt)`, sorted desc by `dm = pd * pdt`. Cap: top 5 each side. Output: `{'agree': [...], 'disagree': [...]}`. With PSEUDO_COUNT=2, `pa + pd = 1` exact → `pa > 0.5 ⟺ pd < 0.5`, so the same tid cannot appear in both lists. - **`conv_repness` grows `mod_out` kwarg**, forwarded to both `select_rep_comments_df` and `consensus_stats_df`. Consensus is now run unconditionally — Clojure has no `len(groups) > 1` guard. - **`_stats_row_to_dict` deleted** — orphan after D11. ## Caller (`conversation.py`) `_compute_repness` passes `mod_out=self.mod_out_tids` to `conv_repness`, matching Clojure's mod-out propagation (repness.clj:222 and :296). ## Downstream output shape change `conv.repness['consensus_comments']` was a flat list with `{repful: 'consensus', comment_id, avg_agree, stats}` entries. After D11 it's `{'agree': [entries], 'disagree': [entries]}` matching Clojure's math-blob shape. Each entry has Python-convention keys (decision S1): `{comment_id, n_success, n_trials, p_success, p_test}`. Updated consumers in the test suite: - `tests/test_repness_smoke.py::test_repness_structure` — iterates the new dict shape. - `tests/test_pipeline_integrity.py::test_full_pipeline` — same. External downstream consumers (`client-report/normalizeConsensus.js`) may need a parallel update; flagged in the decisions log for batch review. ## Tests (13 new in `tests/test_discrepancy_fixes.py`) - `TestD11ConsensusStatsDf` (5): basic counts, pseudocount pa/pd smoothing, ns=0 uninformative fallback, mod_out tid filter, **ns-includes-PASS Clojure parity**. - `TestD11SelectConsensusBoundary` (8): empty input, clear agree consensus, clear disagree consensus, divisive (no consensus), top-5 cap, entry-key shape, disagree-side key mapping (n_success ← nd, p_success ← pd, p_test ← pdt), mutually-exclusive agree/disagree lists. ## `ns`-PASS fix (Clojure parity) After D11 was first landed (PR #2567), the real-data test `test_consensus_matches_clojure` showed 3-5/5 overlap on cold_start. Investigation revealed that Clojure's `:ns` (via `count-votes` with `filter identity` — repness.clj:56-61) INCLUDES PASS votes (`0` is truthy in Clojure), while Python's `ns = na + nd` excluded them. Fixed here by switching `consensus_stats_df` to count via `vote_matrix_df.notna().sum(axis=0)`. After the fix, 3 of 4 dataset variants (vw-incremental, vw-cold_start, biodiversity-cold_start) match Clojure exactly. The `biodiversity-incremental` variant still mismatches on the disagree side (likely residual upstream PCA/KMeans group-membership divergence) — remains `xfail(strict=False)` and tracked in the journal. (The companion fix for `compute_group_comment_stats_df` ships in a separate pre-D10 commit `qyskkqkovtmn`.) ## B1 + B2 sub-agent fixes (relocated from D12 per batch review 2026-06-11) These two fixes were originally landed in PR #2568 (D12) because the D11 sub-agent review happened AFTER D11 had been pushed. They belong in D11, so they are squashed into this commit: - **B1** (`polismath/conversation/conversation.py:830-837`): the no-groups branch of `_compute_repness` returned `'consensus_comments': []` (list). After D11, the public shape is the dict `{'agree': [...], 'disagree': [...]}`. Downstream consumers (test_repness_smoke, test_pipeline_integrity) iterate the dict shape and would crash on the legacy list. Fixed to always return the dict shape, even with no groups. - **B2** (`tests/test_legacy_repness_comparison.py:197-205`): the legacy-comparison test extracted `py_consensus = py_results.get( 'consensus_comments', [])` and treated it as a flat list. Post-D11 this is a dict, so the ID extraction silently produced an empty set. Fixed to flatten the dict (agree + disagree) for the legacy comparison, with a `legacy fallback` branch in case the value is still a list. ## Suite delta - Pre (post-D10): 313 passed, 12 skipped, 58 xfailed. - Post (this PR): 330 passed, 12 skipped, 55 xfailed, 3 xpassed. - Delta: +17 passed, -3 xfailed (D11 real-data test now passes on 3 of 4 dataset variants; biodiversity-incremental remains xfail(strict=False)). Zero regressions. ## /goal mode Part of an autonomous stacked PR series (D10 + D11 + D12 + goldens) per user request. Decisions are documented for batch review in `~/polis/D10_D11_D12_GOLDENS_DECISIONS.md`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> commit-id:bf7eabfe * feat(delphi): D12 — comment priorities (PR 11) Implements `comment-priorities` matching Clojure (math/src/polismath/math/conversation.clj:648-679). Pre-D12 Python emitted nothing for `comment_priorities`, which forced the TypeScript server's `getNextPrioritizedComment` to fall back to uniform random comment routing. Sits on top of PR 9 (D11) in the spr stack. ## New helpers in `pca.py` - `pca_project_cmnts(center, comps) -> np.ndarray` (shape (n_cmnts, n_components)): Vectorized projection. Closed-form derived from Clojure's sparsity-aware projection (pca.clj:134-178) collapsing to a single non-nil column per comment: proj[i] = -sqrt(n_cmnts) * (1 + center[i]) * [pc1[i], pc2[i]] - `compute_comment_extremity(cmnt_proj) -> np.ndarray`: L2 norm per row. Clojure `with-proj-and-extremtiy` (conversation.clj:341-352). ## New module-level functions in `conversation.py` - `META_PRIORITY = 7` (Clojure conversation.clj:319). - `importance_metric(A, P, S, E) -> float` (Clojure conversation.clj:311-315). - `priority_metric(is_meta, A, P, S, E) -> float` (Clojure conversation.clj:321-330). Squared output. Meta: `META_PRIORITY^2 = 49`. Non-meta: `(importance * (1 + 8*2^(-S/5)))^2` — the decay factor lets new comments bubble up; importance falls as votes accumulate. ## New `Conversation._compute_comment_priorities()` method Wired into `recompute()` after `_compute_repness()`. For each tid: - Compute extremity from `pca_project_cmnts` + `compute_comment_extremity`. - Aggregate A/D/S across all groups via `_compute_group_votes()`. - Derive P = S - (A + D) (Clojure conversation.clj:661). - Check `tid in self.meta_tids` for the meta branch. - Call `priority_metric` and store under `self.comment_priorities[int(tid)]`. The serialization infrastructure (`to_dict`, `to_dynamo_dict`, underscore→hyphen conversion in `_convert_inner`) already existed but was emitting empty. Now populated. ## B1 + B2 fixes folded in from D11 sub-agent review - **B1**: `conversation.py:834` no-groups early-return now emits `consensus_comments: {'agree': [], 'disagree': []}` (dict) instead of `[]` (list) — restores shape consistency with the new D11 shape. - **B2**: `test_legacy_repness_comparison.py:197` flattens the new consensus dict before iterating, instead of crashing on `'agree'.get('comment_id', '')`. ## Tests (11 new in `tests/test_discrepancy_fixes.py`) - `TestD12PCAProjectComments` (5): output shape, formula verification per row, empty inputs, L2 extremity, empty extremity. - `TestD12PriorityMetrics` (6): `importance_metric` matches Clojure reference value `4/9` (from conversation.clj:335 comment), extremity boost behavior, meta priority constant = 49, non-meta squared formula, decay factor monotonicity (low-S boosts, high-S fades), `META_PRIORITY == 7`. ## Real-data test xfailed: Clojure blob has constant priorities vw and biodiversity blobs both have EVERY tid set to priority = 49.0 (= META_PRIORITY^2). Likely caused by Clojure's `(if 0 ...)` truthiness quirk — 0 is truthy in Clojure (only nil/false are falsy), so any value returned by `(get meta-tids tid 0)` triggers the meta branch. Python correctly distinguishes meta from non-meta via Boolean set membership, producing varied priorities 0.18-31.46. Spearman comparison meaningless when Clojure side has zero variance (returns nan). Test xfailed with full reason. D12 logic verified by the 11 synthetic tests. Logged for batch review — Python may be MORE correct than Clojure here. ## Suite delta - Pre (post-D11): 325 passed, 12 skipped, 58 xfailed. - Post (this PR): 336 passed, 12 skipped, 56 xfailed, 2 xpassed. - Delta: +11 (the 11 new D12 synthetic tests), 0 failed, 2 xfailed → xpassed (the cold_start D12 tests now run cleanly; the new xfail is on a different test). ## /goal mode Autonomous stack PR (D10 + D11 + D12 + goldens). Decisions documented in `~/polis/D10_D11_D12_GOLDENS_DECISIONS.md` for batch user review. D11 sub-agent flagged additional concerns (B3: math-blob plumbing hardcodes empty `consensus` in to_dict/to_dynamo_dict; D11 data computed-but-unused at serialization layer). NOT addressed here — larger scope than D11/D12, deliberate per S1. Documented for batch review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> commit-id:d02440bf * feat(delphi): plumb D11 consensus + D12 priorities through to_dict / to_dynamo_dict commit-id:ff4764e4 * fix(delphi): address Copilot review on D10-D12 stack (consensus blob keys, priority serialization, defensive fixes) Verified triage of all 83 Copilot review threads on PRs #2564-#2573: 1 real blocker + 4 escalations confirmed by direct code verification; the rest nitpicks / already-fixed / intentional bug-mirrors. All fixes TDD RED->GREEN. - Consensus entry keys -> Clojure blob shape {tid, n-success, n-trials, p-success, p-test} (was Python-convention comment_id/n_success/...). Narrows the S1 deferral: these entries flow raw into result['consensus'], where server-helpers.ts:298-313 and client-report majorityStrict.jsx:23-27 pluck `tid` - the Python keys broke both consumers. Rep-comment entries keep comment_id until the deferred math-blob alignment PR. - DynamoDB writer read D11 consensus from a key to_dynamo_dict never emits (repness.consensus_comments) -> always wrote the empty default; D11 data never reached Delphi_PCAResults. Now reads top-level result['consensus']. The round-trip test had stubbed to_dynamo_dict with the writer's wrong nested shape - stub corrected to the real producer shape. - to_dynamo_dict comment priorities: int(value) -> Decimal-preserving. int() floors sub-1 priorities to 0 (real formula spans ~0.18-31.46 per D12.6), which the TS server's weighted routing reads as "no priority data". Harmless today (bug-mirror pins 49.0), landmine once #2571 resolves. Legacy writer path Decimal-wrapped too. - DynamoDB reader normalizes legacy list-shaped consensus to the dict shape (pre-D11 blobs). - mod_out truthiness -> `is not None` x2 in repness.py (numpy array/Index-safe). - _compute_comment_priorities fails closed (error log + empty dict) on PCA/columns desync instead of silently zip-truncating extremities. - bench_repness.py imported the 14a-deleted comment_stats (ImportError on any benchmark run); benchmark import test added. - Per-variant xfails replace blanket xfail(strict=False) on D11/D12 real-data tests, so the variants that match Clojure gate again. DISCOVERY: scoping the blanket unmasked two previously-invisible incremental divergences (bg2018-incremental, pakistan-incremental consensus vs Clojure) that the blanket had silently absorbed - documented as known-bad incremental xfails, same family as biodiversity-incremental, deferred to the sequential-parity work. 3 pre-existing CCR failures (verified identical on edge 722640eb0) marked with precise per-variant reasons. PGR regression tests skip with the 2026-06-11 goldens-deferral reason - the mark S3-5 claimed to add but never committed. - test_repness_smoke consensus-entry structure assertion updated to the Clojure key shape (exact key-set check). - ns docstrings corrected (ns includes PASS post ns-PASS fix; pa+pd <= 1 reasoning in select_consensus_comments_df). - PERF deferral comment on the _compute_group_votes scan in _compute_comment_priorities (follow-up issue to be filed). Baseline (2026-07-04, stack top, --include-local): 13 failed / 476 passed / 18 skipped / 143 xfailed. All 13 accounted for: 10 stale-PGR comparisons (now skipped per deferral), 3 pre-existing CCR (now precise xfails). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CiESAwcrZ6BcVfdkumnxa commit-id:4cc93a23 * fix(delphi): Decimal-convert D11 consensus for DynamoDB write Caught by CI's test_math_pipeline_runs_e2e on the stack branch (2026-07-05): "Float types are not supported. Use Decimal types instead." at the Delphi_PCAResults put_item. Once the writer read top-level `consensus` (the key to_dynamo_dict actually emits — fixed in the PR below this one), REAL D11 consensus data flowed to DynamoDB for the first time, carrying float p-success/p-test values. Only the LEGACY writer branch Decimal-converted its payload; the pre-formatted branch wrote `dynamo_data['consensus']` raw into the Item, and boto3's TypeSerializer rejects raw floats. Invisible to every local gate, three ways: the e2e test needs DynamoDB (local skip list), the consensus round-trip tests use MagicMock (no TypeSerializer runs), and the float-serialization pins covered priorities but not consensus. Fix, TDD RED->GREEN with boto3's REAL TypeSerializer: - to_dynamo_dict: consensus surface now passes through float_to_decimal (same treatment as pca and comment_priorities in the same function). - DynamoDB writer Site 1: belt-and-braces _replace_floats_with_decimals at the boto3 boundary, mirroring the legacy branch (idempotent on already-converted data). - New TestToDynamoDictConsensusSerialization (Layer 2c) pins the exact failure through the real serializer; two mock-era expectations updated from float-identity to value-preserving comparisons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CiESAwcrZ6BcVfdkumnxa commit-id:22c91871 * test(delphi): require_dynamodb skips locally, fails loudly in CI DynamoDB-gated tests (test_math_pipeline_runs_e2e, test_batch_id) needed a blanket --ignore in local runs because require_dynamodb hard-failed when the service was absent. Now: pytest.skip locally with a how-to-run hint (`docker run --rm -d -p 8002:8000 amazon/dynamodb-local` + DYNAMODB_ENDPOINT), pytest.fail in CI — where DynamoDB is provisioned and its absence is an infrastructure failure that must not silently disable the only end-to-end gate (the 2026-07-05 consensus-float crash was caught precisely because CI runs it). Discriminator is GITHUB_ACTIONS, deliberately NOT the generic CI: local supply-chain wrappers (SafeDep pmg) inject CI=true into wrapped package-manager invocations, which would force the loud-fail path on developer machines (observed 2026-07-05 on `uv run` via the pmg alias). Verified matrix: local+down 5 skipped / local+up(8002) e2e PASSES against real DynamoDB (the consensus-Decimal fix validated end-to-end) / GITHUB_ACTIONS+down errors loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CiESAwcrZ6BcVfdkumnxa commit-id:bc3ca29e * docs(delphi): archive 33 stale leftover docs, fix stale references Audit of delphi/docs/ against the current code (2026-06-11). Per review feedback (Colin): the stale docs are MOVED to delphi/docs/archive/ rather than deleted — they capture the original research and design intent of the 2025 build-out, which is an independent deliverable worth keeping as raw material for future models to mine. archive/CLAUDE.md marks the folder as historical, instructs agents not to treat it as current documentation, and indexes each file's original purpose and the reason it was archived. - Move 33 stale docs to docs/archive/ byte-identical (git records renames): completed one-off fix memos and session logs, unimplemented design proposals (IGAS topic-consensus metrics, job-id migration, DAG job system, spatial topic prioritization, UMAP viz plan, 16-week topic-agenda migration), and docs describing deleted architecture (legacy poller, run_tests.py, simplified tests, eda_notebooks, 600/802 scripts, custom power-iteration PCA). - Amend 15 surviving docs: status headers on handoffs and deep-analysis-for-julien/07 (which D-fixes are merged vs open), deleted-script references (start_poller.sh, 600_*.py), table name DelphiJobQueue -> Delphi_JobQueue, uv instead of pip/venv, TopicAgenda.jsx -> .tsx, versioned-key delimiter correction. - Rewrite DOCUMENTATION_DIRECTORY.md to index surviving docs + archive. - Fix references to moved docs and deleted scripts in delphi/CLAUDE.md and DELPHI_JOB_SYSTEM_TROUBLESHOOTING.md (run_delphi.sh -> run_delphi.py, start_poller.sh -> start_poller.py, nonexistent reset_database.sh, dead absolute-path link to DATABASE_NAMING_PROPOSAL.md). Kept in place deliberately, as they document still-unfixed bugs: ZID_EXPOSURE_AUDIT.md (zid still exposed in delphi API responses) and TOPIC_LABEL_MISALIGNMENT_ANALYSIS.md (700_datamapplot label sorting). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> commit-id:4336711e * fix(delphi): keep Clojure group-id order — resolves the gid 0-1 label swap Root cause of the label swap confirmed by the S3-4 trace (2026-06-11: Python g0 = Clojure g1 with 50/50 identical membership on vw-cold_start): _compute_clusters re-sorted group clusters by size (descending) and reassigned ids. Clojure assigns group ids by first-k-distinct encounter order over base-cluster centers (init-clusters, clusters.clj:55-64), keeps them through merge lineage, and orders output by sort-by :id (conversation.clj:437) - it never re-orders by size. The base-cluster level already preserved k-means id order for exactly this reason (K-inv); the group level now follows the same rule. Fix: remove the size re-sort + id reassignment; keep k-means label order (sklearn preserves init-index-to-label correspondence, and the base-center row order is already Clojure-parity per K-inv). Pinned by a synthetic test: with the smaller group's center encountered first, group id 0 must be the smaller group (fails under any size re-sort). Test-gate harvest, verified against a full --include-local run: - TestD8FinalizeStats::test_repful_matches_clojure_blob: xfail LIFTED on 9/11 variants (all but vw-incremental / pakistan-incremental, which keep per-variant xfails for residual incremental trajectory divergence). - D9 significance-sets + D10 rep-selection: biodiversity-cold_start now matches Clojure exactly and gates; other variants keep per-variant xfails (residual per-(gid,tid) membership/stat divergence). - z-values / rat-values xfail reasons corrected: the label swap is now FALSIFIED as their cause (fix did not flip them); residual cause is group-membership divergence. - D12 priorities known-bad incremental lists refined: FLI-incremental and bg2050-incremental Clojure blobs carry the all-49 truthy-0 signature, match Python's #2571 mirror, and now gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CiESAwcrZ6BcVfdkumnxa commit-id:868e1ec0 * chore(delphi): remove vestigial np.random.seed(42); carry Clojure seeding note The global np.random.seed(42) in cluster_dataframe was the only `random` reference in the module and seeded an RNG nothing ever draws from: k-means init is first-k-distinct (deterministic by construction), and cluster_dataframe is not on the production path anyway (production uses kmeans_sklearn exclusively; the sole caller is tests/test_clusters.py). Also drops the module's dead `import random`. Evidence (2026-07-05, scratch/determinism_check.py + journal): 5 consecutive full-pipeline runs on vw + biodiversity are bit-for-bit identical except math_tick (a wall-clock version counter, varies by design). The determinism comes from first-k-distinct + n_init=1 + explicit random_state, not from this global seed. Per Julien: the original Clojure author's verbatim note on seeding (pca.clj:80-81 — "Should really throw a parallelizable random number generator in the equation here... With seeds fed in and persisted... XXX") now lives in pca.py next to the random_state setting, with the seeding-history context (Clojure never fixes a seed; k-means deterministic; PCA cold-start rand unseeded, warm-started after). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CiESAwcrZ6BcVfdkumnxa commit-id:7c53868d * feat(delphi): powerit-pca port (Clojure-parity PCA) behind legacy-mode flag Numpy port of Clojure's power-iteration PCA (pca.clj:38-105): per-component power iteration with fixed iteration count (iters=100, the Clojure default), Gram-Schmidt deflation, and a start_vectors parameter for warm-start pinning (the replay harness's mechanism for collapsing cold-start jitter, and a prerequisite for the pure-Python R2 replayer — see docs/REPLAY_HARNESS_DESIGN.md section 9/10). Flag: POLISMATH_PCA_IMPL env var, read at call time in pca_project_dataframe. 'powerit' (DEFAULT - legacy/parity mode) or 'sklearn' (the improved path, previous behavior). First instance of the permanent legacy-vs-improved dual-path pattern (Julien, 2026-07-05). Imputation and sparsity scaling are untouched; only the eigen-solver branches. Data-prep parity verified: Clojure imputes nil -> column average (conversation.clj:360-380) = Python's nanmean imputation. Documented decision - deterministic cold start: Clojure draws an UNSEEDED random start vector (pca.clj:79-82); Python defaults to a fixed deterministic start so the pipeline stays bit-for-bit reproducible (the 2026-07-05 determinism verification is a project invariant). Power iteration converges to the same dominant eigenvector for almost any start, so the fixed start is one specific draw of Clojure's random one; start_vectors overrides for pinning. Carries the requested TODO: switch to a proper convergence criterion once we move to improving the Python implementation. Silhouette guard (companion fix — required by making powerit the default): - calculate_silhouette_sklearn (clusters.py) now treats any n_labels >= n_samples clustering as undefined and returns the neutral 0.0 sentinel instead of letting sklearn raise. sklearn's silhouette_score requires 2 <= n_labels <= n_samples - 1; the old guard only covered n_labels <= 1 / n_samples <= 1. - Why it surfaced here: making powerit the default projects some small conversations onto exactly two base clusters, which feeds a 2-point / 2-label silhouette call in the group-cluster k-selection loop (conversation.py). That raised "Number of labels is 2. Valid values are 2 to n_samples - 1", crashing TestConversation.test_recompute and erroring 8 test_serialization_unfolding cases. All green now under BOTH powerit and sklearn. - The new guard is a strict superset of the old one — valid clusterings (n_labels < n_samples) are unchanged, and with only two base clusters the k-selection loop is forced to k=2 regardless, so the selected clustering is identical; the fix only removes the crash. - 3 unit tests added: tests/test_clusters.py::TestCalculateSilhouetteSklearn (2-samples/2-labels returns 0.0 not raise; single-label stays 0.0; a valid 3-sample/2-label clustering still returns a genuine score). Results: - 17 new tests (tests/test_powerit_pca.py): correctness vs numpy eigh, deflation orthogonality, determinism, start-vector honoring, flag behavior, solver agreement (PC1 0 deg, PC2 2.1e-3 deg on the reference fixture). - CCR angle deltas vs sklearn baseline: <= 0.02 deg across all datasets and variants; 28 passed / 27 xfailed unchanged. - Evidence: the bg2050-incremental PC2 miss (10.7086 deg > 10 deg) is BIT-IDENTICAL under powerit - upstream incremental-state divergence, NOT solver difference. Its xfail annotation stands. - Benchmark (vw, 69x125): powerit 0.765 ms avg vs sklearn 1.007 ms - ~1.3x faster at this size. bench_pca.py --compare-impls added. - Final gate: 304 passed / 33 skipped / 116 xfailed / 0 failed (baseline 283/33/116 + 17 new + 4 benchmark-import), plus the 3 silhouette-guard unit tests above. Also rewords the determinism-evidence pointer to cite the journal entry (tracked) instead of the gitignored scratch script (Copilot on #2590, convergent with internal review). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CiESAwcrZ6BcVfdkumnxa commit-id:0ae4ef55 * docs(delphi): parity journal 2026-07-04/07, PLAN refresh, replay-harness design Three docs deliverables from the 2026-07-04/05 host sessions: - CLJ-PARITY-FIXES-JOURNAL.md: full session entries — spr-squash reconciliation (closed+mergedAt:null = landed; squash titles lie), Copilot triage of all 83 threads (1 real blocker + 5 verified escalations incl. the DynamoDB consensus data-loss), review-fix PR #2586, per-variant xfail scoping (which unmasked bg2018/pakistan incremental consensus divergences the blanket had absorbed), gid label-swap root cause + fix (#2589), seed removal (#2590), determinism verification (5 runs bit-identical except math_tick), Clojure randomness facts (never seeds; fixed-iteration power iteration; unseeded :twister sampling), R2 python-only constraint, powerit-pca GO. - PLAN_DISCREPANCY_FIXES.md: D10/D11/D12 status rows corrected (were still "VM draft — NEEDS REWORK"; actually code-complete open PRs). - REPLAY_HARNESS_DESIGN.md (NEW): design for the replay harness (H) — schedule spec as first-class input, Python driver (the future R2 forward model, python-only per Julien 2026-07-05), Clojure driver narrowed to R1 certification (Mode A pure conv-update reduce; blob capture sufficient, EDN on demand), nondeterminism policy (tolerance classes, warm-start pinning, self-jitter measurement), storage/provenance, phased build plan H-0..H-D. Also appends the 2026-07-06/07 session entry: the silhouette-guard fix for the powerit-PCA default (#2591) — powerit collapsing a small conversation to two base clusters fed a 2-point/2-label silhouette call that sklearn rejects; calculate_silhouette_sklearn now returns 0.0 when n_labels >= n_samples (strict superset of the old guard, chosen clustering unchanged). Verified green; details in the journal. The journal's "Determinism verification" entry is the tracked evidence pointer cited from pca.py (Copilot on #2590). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CiESAwcrZ6BcVfdkumnxa commit-id:d2fa477e * feat(delphi): Clojure-parity math fixes + storage-v2/replay design (squash of 15 PRs) SQUASH MERGE of the 15-PR Clojure-parity + design stack into edge (spr stack, PRs #2564–#2597). GitHub's default squash message keeps only the top PR's description; this message preserves every squashed PR's commit message so no history is lost. What it does: • Clojure-parity math fixes D10–D15 and supporting changes (rep-comment selection, consensus selection, comment priorities, group-id order, ns/PASS-vote counting, repness cleanup) so the Python math pipeline matches the legacy Clojure output. • powerit-pca legacy-mode PCA port (Clojure-parity power iteration). • DynamoDB consensus serialization/robustness fixes (Decimal conversion, dict-shape normalization incl. present-but-None guard). • Test-infra: require_dynamodb skips locally / fails loudly in CI. • Docs: parity journal + PLAN refresh, replay-harness design (R1/R2), storage-v2 / job-id reproducibility design, and archival of stale docs. ─────────────────────────────────────────────────────────────────────── Squashed PRs (bottom → top of stack): ─────────────────────────────────────────────────────────────────────── ▁▁▁ #2564 ▁▁▁ refactor(delphi): delete dead scalar paths in repness.py (PR 14a) The scalar implementations in `delphi/polismath/pca_kmeans_rep/repness.py` were test-only — production calls only `compute_group_comment_stats_df`, `select_rep_comments_df`, and `select_consensus_comments_df` (via `conv_repness`). Maintaining two parallel implementations created "where do I put this helper?" ambiguity for the upcoming D10/D11/D12 fixes (all of which add new helpers to `repness.py`) and obscured the structure of the production path. Foundation pass before D10/D11/D12 + PR 14b/14c. No behavioural change — production path untouched. ## Production code (`delphi/polismath/pca_kmeans_rep/repness.py`) DELETED (445 lines): - Primitives: `prop_test`, `two_prop_test` (no production callers). - Orchestration: `comment_stats`, `add_comparative_stats`, `repness_metric`, `finalize_cmt_stats`, `passes_by_test`, `best_agree`, `best_disagree`, `select_rep_comments`, `select_consensus_comments`. - Unused helper: `calculate_kl_divergence` (no callers anywhere in repo). KEPT: - `z_score_sig_90`, `z_score_sig_95` — trivial threshold checks consumed scalar-side; vectorizing would not save lines. ENRICHED: - `prop_test_vectorized` and `two_prop_test_vectorized` docstrings now embed the scalar-equivalent closed-form algebra. The formulas stay readable even though the scalar functions are gone. ## Tests DELETED entirely: - `tests/test_old_format_repness.py` (557 lines, scalar-only sibling of `test_repness_unit.py`). DELETED classes/methods in `tests/test_repness_unit.py`: - `TestCommentStats`, `TestSelectionFunctions`, `TestConsensusAndGroupRepness` (all scalar-only). - `TestStatisticalFunctions::test_prop_test`, `::test_two_prop_test`. MIGRATED to single vectorized DataFrame calls in `tests/test_discrepancy_fixes.py`: - D4/D5/D6 BlobInjection classes — build a DataFrame from the blob's `repness` entries, run one `prop_test_vectorized` / `two_prop_test_vectorized` call, compare element-wise. Tests the actual production code path, produces cleaner diagnostics via `.to_string()`. - `TestD5ProportionTest::test_prop_test_matches_clojure_formula` (consolidated with the n=0 boundary case). - `TestD6TwoPropTest::test_two_prop_test_matches_clojure_formula` (+ edge cases consolidated, + pi_hat=1 boundary cases). CONSOLIDATED in `tests/test_discrepancy_fixes.py`: - `TestD8FinalizeStats`'s 7 scalar boundary tests collapse into a single parametrized DataFrame test `test_repful_classification_boundary` that exercises the production `np.where(rat > rdt, 'agree', 'disagree')` logic. All boundary cases preserved (rat<rdt, rat>rdt, rat==rdt non-zero, rat==rdt==0, negative z-scores). DELETED redundant tests: - `TestSyntheticEdgeCases::test_prop_test_matches_clojure_formula_synthetic` (duplicated by migrated TestD5ProportionTest). - `TestSyntheticEdgeCases::test_clojure_repness_metric_product` (duplicated by migrated TestD7RepnessMetric). - `TestSyntheticEdgeCases::test_clojure_repful_uses_rat_vs_rdt` (purely tautological). Cross-checks in `tests/test_repness_unit.py::TestVectorizedFunctions` now use `_prop_test_reference` and `_two_prop_test_reference` closed-form staticmethods in place of scalar calls. ## Suite delta - Pre (edge @ 2dce7385f): 330 passed, 12 skipped, 58 xfailed. - Post: 295 passed, 12 skipped, 58 xfailed. - Delta: -35 passed, 0 failed, 0 new xfailed. Matches deleted scalar-test count exactly. ## For PR 14c (readability refactor — runs later) The deleted scalar code is the readability reference for PR 14c. Retrieve via: git show <this-commit>~1:delphi/polismath/pca_kmeans_rep/repness.py \ | sed -n '161,302p' Specifically (pre-deletion line numbers): `comment_stats` 161-201, `add_comparative_stats` 203-235, `repness_metric` 237-271, `finalize_cmt_stats` 273-301. Clojure originals at `math/src/polismath/math/repness.clj:78-100,173-188,191-200`. ## Documentation - `delphi/docs/PLAN_DISCREPANCY_FIXES.md`: added PR 14a row to the stack cross-reference table. - `delphi/docs/CLJ-PARITY-FIXES-JOURNAL.md`: appended "Session: PR 14a — Scalar deletion (2026-06-11)" entry with the full scope, suite delta, and the `git show` recipe for PR 14c. ## Out of scope (handed off separately) - Pyright pandas-stubs noise (10+ false positives on `pd.DataFrame(columns=...)` and `df['col'] = value` in `compute_group_comment_stats_df`) is pre-existing on edge HEAD; PR #2560's pyright config didn't set rule overrides. Handoff: `~/polis/HANDOFF_PYRIGHT_PANDAS_STUBS.md`. Tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> ▁▁▁ #2570 ▁▁▁ fix(delphi): ns includes PASS votes (Clojure parity, pre-D10) Bug --- `compute_group_comment_stats_df` in `delphi/polismath/pca_kmeans_rep/repness.py` computed `ns = na + nd` and `total_votes = total_agree + total_disagree`, silently dropping PASS (0) votes from every vote count. Clojure reference: `math/src/polismath/math/repness.clj:56-61, :70`: (defn- count-votes [votes & [vote]] (let [filt-fn (if vote #(= vote %) identity)] (count (filter filt-fn votes)))) ... :ns (fnk [votes] (count-votes votes)) `count-votes` with no `vote` argument uses `identity` as the filter predicate. In Clojure 0 is truthy, so `(filter identity ...)` keeps every non-nil entry — including PASS. Therefore Clojure `ns = na + nd + np` (PASS count). Every downstream metric that consumes `ns` — `pa`, `pd`, `pat`, `pdt`, `ra`, `rd`, `rat`, `rdt`, `agree_metric`, `disagree_metric`, and the upcoming D11 `consensus_stats_df` — was off whenever PASS votes existed. Fix --- Both `total_counts` and `group_counts` now compute the count column via `('vote', 'size')` directly in the pandas groupby aggregation. The frame is `dropna(subset=['vote'])`-filtered upstream, so `size()` counts exactly the non-NaN rows — including PASS (0). This is the Clojure `(count (filter identity votes))` recipe verbatim. Both sites carry a comment citing the Clojure reference. Why D5 BlobInjection didn't catch this -------------------------------------- D5's blob-injection tests pull `(n-success, n-trials)` directly from the Clojure blob's `repness` entries and feed them to `prop_test_vectorized`. They bypass `compute_group_comment_stats_df` entirely, so anything downstream of `ns = na + nd` was invisible. The same gap will recur for every fix whose stage-inputs are built by Python code. Pure-formula tests over a tiny vote matrix are the only RED path. Tests added ----------- `TestNsIncludesPassVotes` in `tests/test_repness_unit.py`: - `test_ns_includes_pass_votes` — single comment, 2 agree / 1 disagree / 2 pass → `na=2, nd=1, ns=5`. - `test_ns_all_pass_column` — all-PASS column → `na=0, nd=0, ns=3`. - `test_ns_mixed_with_nan_only_explicit_votes_count` — NaN never counts; PASS always does. - `test_other_votes_includes_other_group_pass` — `other_votes` (the complement of group `ns`) also includes out-of-group PASS. Suite delta ----------- Pre: 295 passed, 12 skipped, 58 xfailed (PR 14a baseline). Post: 299 passed, 12 skipped, 58 xfailed. Delta = +4 (the new tests). No pre-existing test broke — the existing `TestVectorizedFunctions` fixtures used only AGREE/DISAGREE/NaN and were never sensitive. Cascade to D11 -------------- D11's `consensus_stats_df(vote_matrix_df)` (whole-conversation counterpart) will mirror the same recipe at the conversation level. This fix lands BEFORE D10 in the stack so D11's implementation, when it arrives, starts from the corrected ns semantics. D11 should follow the same pure-formula test pattern. Goldens ------- DEFERRED. Re-recording is gated on sklearn-KMeans-seeding consensus; no golden values shift at the goldens commit until D10/D11/D12 land. ▁▁▁ #2566 ▁▁▁ feat(delphi): D10 — Clojure-parity rep comment selection (PR 8) Replaces the pre-D10 botched-port `select_rep_comments_df` with a single-pass reduce that mirrors Clojure `select-rep-comments` (math/src/polismath/math/repness.clj:212-281). Sits on top of PR 14a in the spr stack. ## Helpers added (top-level in `repness.py`) - `passes_by_test(s)` — Clojure `passes-by-test?` (repness.clj:165-170). OR'd on (rat, pat) and (rdt, pdt) z-sig-90. NO `pa >= 0.5` gate; the pre-D10 Python gate was an over-restriction with no Clojure analog. - `beats_best_by_test(s, current_best_z)` — Clojure `beats-best-by-test?` (repness.clj:133-139). Strict `>` on `max(rat, rdt)`. - `beats_best_agr(s, current_best)` — Clojure `beats-best-agr?` (repness.clj:142-162). Four-branch agree-priority logic: 1. na == 0 AND nd == 0 → reject. 2. current_best AND current_best.ra > 1.0 → compare 4-way signed product `ra * rat * pa * pat`. 3. current_best (else, ra <= 1.0) → compare `pa * pat` only. 4. No current_best → accept if `z90(pat)` OR `(ra > 1.0 AND pa > 0.5)`. `current_best` stores the RAW row (Clojure repness.clj:250) so the ra/rat/pa/pat surface stays available across iterations. - `_finalize_row_for_output(row, *, is_best_agree=False)` — Clojure `finalize-cmt-stats` (repness.clj:173-188) + best-agree flagging (repness.clj:262-264). Emits `best_agree=True` and `n_agree=na` for the best-agree slot. ## `select_rep_comments_df` rewrite Signature now: `(stats_df, mod_out=None) -> List[Dict[str, Any]]`. Drops the `agree_count` / `disagree_count` kwargs (Clojure has only a cap of 5). Per-row state `{sufficient, best, best_agree}` updated by the helpers. Final assembly: dedup best_agree from sufficient → sort by metric (agree_metric for repful=='agree', disagree_metric for 'disagree') → prepend finalized+flagged best_agree → take 5 → agrees-before-disagrees. The caller in `conv_repness` drops the `_stats_row_to_dict` wrapping step (the new function returns finalized dicts directly). ## Two pre-D10 bugs fixed alongside the rewrite - `pa >= 0.5 / pd >= 0.5` over-gate in the passing filter — removed (no Clojure analog). - "Fill from other category" + "first row" fallback blocks — deleted. The `:best` / `:best_agree` mechanism IS the Clojure fallback. ## Tests (18 new in `tests/test_discrepancy_fixes.py`) - TestD10PassesByTest (4): agree-side, disagree-side, neither, no pa-gate. - TestD10BeatsBestByTest (3): None-best, max(rat,rdt), strict `>`. - TestD10BeatsBestAgr (6): one per Clojure branch + boundary. - TestD10SelectRepCommentsBoundary (5): empty input, single unvoted row → best fallback, sufficient-empty-best-agree-only, take-5 cap + agrees-before-disagrees, **the eviction edge case** (best_agree outside sufficient evicting 5th-highest-metric). ## Eviction edge case — flagged `take(5)` runs AFTER prepending best_agree. If `best_agree` was kept by `beats_best_agr` as a non-significant agree-priority fallback (failed `passes_by_test`, qualified via Branch 4) AND `:sufficient` already has 5 entries, the prepend pushes total to 6 and `take(5)` evicts the 5th-highest-metric sufficient entry — possibly a strong dissenting view. Mirrors Clojure exactly for blob parity. `# TODO(parity-eviction)` comment at the take(5) site in the production code; entry added under "Pending — needs team discussion" in PLAN.md; pinned by the synthetic test above. ## Re-xfailed with updated reasons (D14 / D1 upstream divergence) Six per-shared-(gid, tid) blob-comparison tests were previously xfailed as "D5/D6/D7/D8/D10: no shared comments to compare". After D10 there ARE shared comments (overlap ~20% on vw cold_start), but the per-(gid, tid) stats still mismatch because Python and Clojure place different participants in the "same" group ID. That's upstream PCA/KMeans group-membership divergence (D14 / D1), not D10. Updated xfail reasons point at D14 / D1. D10 itself is verified by the 18 synthetic tests. Affected: TestD9ZScoreThresholds::test_z_values_match_clojure, TestD5ProportionTest::test_pat_values_match_clojure_blob, TestD6TwoPropTest::test_rat_values_match_clojure_blob, TestD7RepnessMetric::test_repness_metric_matches_clojure_blob, TestD8FinalizeStats::test_repful_matches_clojure_blob, TestD10RepCommentSelection::test_rep_comments_match_clojure. ## Suite delta - Pre (post-14a baseline): 295 passed, 12 skipped, 58 xfailed. - Post: 313 passed, 12 skipped, 58 xfailed. - Delta: +18 passed (the 18 new D10 synthetic tests), 0 failed, no new xfailed. ## Documentation - `delphi/docs/PLAN_DISCREPANCY_FIXES.md`: PR 14a row marked landed (#2564), PR 8 (D10) marked in-flight, eviction concern added to "Pending — needs team discussion". - `delphi/docs/CLJ-PARITY-FIXES-JOURNAL.md`: "Session: PR 8 — D10 rep comment selection (2026-06-11)" entry with full scope, suite delta, and decisions log pointer. ## /goal mode This PR is part of an autonomous run (`/goal`) targeting D10 + D11 + D12 + golden snapshots as a stacked PR series. Decisions made autonomously (key naming, return type, etc.) are documented in `~/polis/D10_D11_D12_GOLDENS_DECISIONS.md` for batch user review at the end of the run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> ▁▁▁ #2567 ▁▁▁ feat(delphi): D11 — Clojure-parity consensus comment selection (PR 9) Replaces the pre-D11 consensus logic (per-group `pa > 0.6 for all` filter, top 2 by `avg_pa`) with a whole-conversation per-comment-stats stage and two independent top-5 lists (agree / disagree), matching Clojure `consensus-stats` + `select-consensus-comments` (math/src/polismath/math/repness.clj:284-323). Sits on top of PR 8 (D10) in the spr stack. ## Production changes (`repness.py`) - **`consensus_stats_df(vote_matrix_df, mod_out=None) -> pd.DataFrame`**: new helper. Whole-conversation per-comment stats (no group split). Output DataFrame indexed by tid with cols [na, nd, ns, pa, pd, pat, pdt]. Vectorized port of Clojure `consensus-stats` (repness.clj:284-290). **`ns` includes PASS** (Clojure parity, repness.clj:56-61): computed as `vote_matrix_df.notna().sum(axis=0)` rather than `na + nd`. Matches Clojure `(count (filter identity ...))` where `0` (PASS) is truthy. - **`select_consensus_comments_df` rewrite**: new signature `(cons_stats) -> Dict[str, List[Dict]]`. Filters and ordering: - agree: `pa > 0.5 AND z-sig-90(pat)`, sorted desc by `am = pa * pat`. - disagree: `pd > 0.5 AND z-sig-90(pdt)`, sorted desc by `dm = pd * pdt`. Cap: top 5 each side. Output: `{'agree': [...], 'disagree': [...]}`. With PSEUDO_COUNT=2, `pa + pd = 1` exact → `pa > 0.5 ⟺ pd < 0.5`, so the same tid cannot appear in both lists. - **`conv_repness` grows `mod_out` kwarg**, forwarded to both `select_rep_comments_df` and `consensus_stats_df`. Consensus is now run unconditionally — Clojure has no `len(groups) > 1` guard. - **`_stats_row_to_dict` deleted** — orphan after D11. ## Caller (`conversation.py`) `_compute_repness` passes `mod_out=self.mod_out_tids` to `conv_repness`, matching Clojure's mod-out propagation (repness.clj:222 and :296). ## Downstream output shape change `conv.repness['consensus_comments']` was a flat list with `{repful: 'consensus', comment_id, avg_agree, stats}` entries. After D11 it's `{'agree': [entries], 'disagree': [entries]}` matching Clojure's math-blob shape. Each entry has Python-convention keys (decision S1): `{comment_id, n_success, n_trials, p_success, p_test}`. Updated consumers in the test suite: - `tests/test_repness_smoke.py::test_repness_structure` — iterates the new dict shape. - `tests/test_pipeline_integrity.py::test_full_pipeline` — same. External downstream consumers (`client-report/normalizeConsensus.js`) may need a parallel update; flagged in the decisions log for batch review. ## Tests (13 new in `tests/test_discrepancy_fixes.py`) - `TestD11ConsensusStatsDf` (5): basic counts, pseudocount pa/pd smoothing, ns=0 uninformative fallback, mod_out tid filter, **ns-includes-PASS Clojure parity**. - `TestD11SelectConsensusBoundary` (8): empty input, clear agree consensus, clear disagree consensus, divisive (no consensus), top-5 cap, entry-key shape, disagree-side key mapping (n_success ← nd, p_success ← pd, p_test ← pdt), mutually-exclusive agree/disagree lists. ## `ns`-PASS fix (Clojure parity) After D11 was first landed (PR #2567), the real-data test `test_consensus_matches_clojure` showed 3-5/5 overlap on cold_start. Investigation revealed that Clojure's `:ns` (via `count-votes` with `filter identity` — repness.clj:56-61) INCLUDES PASS votes (`0` is truthy in Clojure), while Python's `ns = na + nd` excluded them. Fixed here by switching `consensus_stats_df` to count via `vote_matrix_df.notna().sum(axis=0)`. After the fix, 3 of 4 dataset variants (vw-incremental, vw-cold_start, biodiversity-cold_start) match Clojure exactly. The `biodiversity-incremental` variant still mismatches on the disagree side (likely residual upstream PCA/KMeans group-membership divergence) — remains `xfail(strict=False)` and tracked in the journal. (The companion fix for `compute_group_comment_stats_df` ships in a separate pre-D10 commit `qyskkqkovtmn`.) ## B1 + B2 sub-agent fixes (relocated from D12 per batch review 2026-06-11) These two fixes were originally landed in PR #2568 (D12) because the D11 sub-agent review happened AFTER D11 had been pushed. They belong in D11, so they are squashed into this commit: - **B1** (`polismath/conversation/conversation.py:830-837`): the no-groups branch of `_compute_repness` returned `'consensus_comments': []` (list). After D11, the public shape is the dict `{'agree': [...], 'disagree': [...]}`. Downstream consumers (test_repness_smoke, test_pipeline_integrity) iterate the dict shape and would crash on the legacy list. Fixed to always return the dict shape, even with no groups. - **B2** (`tests/test_legacy_repness_comparison.py:197-205`): the legacy-comparison test extracted `py_consensus = py_results.get( 'consensus_comments', [])` and treated it as a flat list. Post-D11 this is a dict, so the ID extraction silently produced an empty set. Fixed to flatten the dict (agree + disagree) for the legacy comparison, with a `legacy fallback` branch in case the value is still a list. ## Suite delta - Pre (post-D10): 313 passed, 12 skipped, 58 xfailed. - Post (this PR): 330 passed, 12 skipped, 55 xfailed, 3 xpassed. - Delta: +17 passed, -3 xfailed (D11 real-data test now passes on 3 of 4 dataset variants; biodiversity-incremental remains xfail(strict=False)). Zero regressions. ## /goal mode Part of an autonomous stacked PR series (D10 + D11 + D12 + goldens) per user request. Decisions are documented for batch review in `~/polis/D10_D11_D12_GOLDENS_DECISIONS.md`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> ▁▁▁ #2568 ▁▁▁ feat(delphi): D12 — comment priorities (PR 11) Implements `comment-priorities` matching Clojure (math/src/polismath/math/conversation.clj:648-679). Pre-D12 Python emitted nothing for `comment_priorities`, which forced the TypeScript server's `getNextPrioritizedComment` to fall back to uniform random comment routing. Sits on top of PR 9 (D11) in the spr stack. ## New helpers in `pca.py` - `pca_project_cmnts(center, comps) -> np.ndarray` (shape (n_cmnts, n_components)): Vectorized projection. Closed-form derived from Clojure's sparsity-aware projection (pca.clj:134-178) collapsing to a single non-nil column per comment: proj[i] = -sqrt(n_cmnts) * (1 + center[i]) * [pc1[i], pc2[i]] - `compute_comment_extremity(cmnt_proj) -> np.ndarray`: L2 norm per row. Clojure `with-proj-and-extremtiy` (conversation.clj:341-352). ## New module-level functions in `conversation.py` - `META_PRIORITY = 7` (Clojure conversation.clj:319). - `importance_metric(A, P, S, E) -> float` (Clojure conversation.clj:311-315). - `priority_metric(is_meta, A, P, S, E) -> float` (Clojure conversation.clj:321-330). Squared output. Meta: `META_PRIORITY^2 = 49`. Non-meta: `(importance * (1 + 8*2^(-S/5)))^2` — the decay factor lets new comments bubble up; importance falls as votes accumulate. ## New `Conversation._compute_comment_priorities()` method Wired into `recompute()` after `_compute_repness()`. For each tid: - Compute extremity from `pca_project_cmnts` + `compute_comment_extremity`. - Aggregate A/D/S across all groups via `_compute_group_votes()`. - Derive P = S - (A + D) (Clojure conversation.clj:661). - Check `tid in self.meta_tids` for the meta branch. - Call `priority_metric` and store under `self.comment_priorities[int(tid)]`. The serialization infrastructure (`to_dict`, `to_dynamo_dict`, underscore→hyphen conversion in `_convert_inner`) already existed but was emitting empty. Now populated. ## B1 + B2 fixes folded in from D11 sub-agent review - **B1**: `conversation.py:834` no-groups early-return now emits `consensus_comments: {'agree': [], 'disagree': []}` (dict) instead of `[]` (list) — restores shape consistency with the new D11 shape. - **B2**: `test_legacy_repness_comparison.py:197` flattens the new consensus dict before iterating, instead of crashing on `'agree'.get('comment_id', '')`. ## Tests (11 new in `tests/test_discrepancy_fixes.py`) - `TestD12PCAProjectComments` (5): output shape, formula verification per row, empty inputs, L2 extremity, empty extremity. - `TestD12PriorityMetrics` (6): `importance_metric` matches Clojure reference value `4/9` (from conversation.clj:335 comment), extremity boost behavior, meta priority constant = 49, non-meta squared formula, decay factor monotonicity (low-S boosts, high-S fades), `META_PRIORITY == 7`. ## Real-data test xfailed: Clojure blob has constant priorities vw and biodiversity blobs both have EVERY tid set to priority = 49.0 (= META_PRIORITY^2). Likely caused by Clojure's `(if 0 ...)` truthiness quirk — 0 is truthy in Clojure (only nil/false are falsy), so any value returned by `(get meta-tids tid 0)` triggers the meta branch. Python correctly distinguishes meta from non-meta via Boolean set membership, producing varied priorities 0.18-31.46. Spearman comparison meaningless when Clojure side has zero variance (returns nan). Test xfailed with full reason. D12 logic verified by the 11 synthetic tests. Logged for batch review — Python may be MORE correct than Clojure here. ## Suite delta - Pre (post-D11): 325 passed, 12 skipped, 58 xfailed. - Post (this PR): 336 passed, 12 skipped, 56 xfailed, 2 xpassed. - Delta: +11 (the 11 new D12 synthetic tests), 0 failed, 2 xfailed → xpassed (the cold_start D12 tests now run cleanly; the new xfail is on a different test). ## /goal mode Autonomous stack PR (D10 + D11 + D12 + goldens). Decisions documented in `~/polis/D10_D11_D12_GOLDENS_DECISIONS.md` for batch user review. D11 sub-agent flagged addi…
When D3 (k-smoother buffer) is ported to Python, both the group AND subgroup smoothers must carry the stale-smoothed-k clamp (contains?-check), not the pre-#2536 unclamped logic. Clojure fixed the group level in #2536 and the subgroup level in #2575 (jc/subgroup-k-smoother-clamp). Added a warning to the D3 section of PLAN_DISCREPANCY_FIXES.md so this isn't re-discovered as a crash. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ngs (#2575) (#2609) PR #2536 clamped smoothed-k in group-k-smoother but left the identical bug in :subgroup-k-smoother. A group's subgroup clustering runs k-means for k in (range 2 (inc M)), where M is count-based on the group's base-cluster count. When group membership drops below a /12 boundary, M falls, the carried smoothed-k can exceed M, and (get group-subgroup-clusterings smoothed-k) returns nil — an empty subgroup clustering that crashes conv-repness (cryptic ISeq error pre-#2536, or #2536's clear IAE on master). conv-update still fails. Mirror #2536's group-level clamp into the per-group subgroup smoother: if the carried smoothed-k is no longer a key in this group's current subgroup clusterings, fall back to this-k (the best available k by silhouette). Test: stale-subgroup-smoothed-k-is-clamped-to-available-subgroup-clusters in conv_edge_cases_test.clj, mirroring the group-level regression test. Full math suite: 53 tests, 150 assertions, 0 failures. Reported by lgelauff (Lodewijk), reproduced via warm-path replay across 50+ public openData conversations. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
) (#2611) The `:comment-priorities` node passed the meta-tid lookup value `(if meta-tids (get meta-tids tid 0) 0)` into `priority-metric`'s `is-meta` argument. `(get meta-tids tid 0)` returns `0` for non-meta tids, and `0` is TRUTHY in Clojure, so `(if is-meta ...)` took the meta branch for EVERY comment — every priority collapsed to `meta-priority^2 = 49`. The TypeScript server's `selectProbabilistically` then degraded to uniform-random next-comment selection, reverting routing to its pre-2018 behavior. Introduced by #1961 (2025-03-15, "cutoff for large-convo processing if > 5000 comments"), which changed the meta-tid default from `(meta-tids tid)` (nil for non-meta) to `(get meta-tids tid 0)`. Fix: pass a real boolean — `(priority-metric (contains? meta-tids tid) ...)`. `contains?` is false for a non-meta tid and false when `meta-tids` is nil, restoring meta=49 / non-meta=importance*novelty. Verified end-to-end: a math image built with this fix regenerates the vw cold-start blob with varied priorities (125 distinct, 5.16-61.95) instead of all-49. Scope: Clojure only, which is what feeds production routing. The Python port still mirrors the bug (`priority_metric` returns META_PRIORITY**2). Un-mirroring it revealed that Python and Clojure priorities are not yet at parity (rank-uncorrelated on vw), so the Python un-mirror + cold-start blob regeneration is deferred to #2571, pending extremity/PCA parity (D1/D1b). See delphi/docs/CLJ-PARITY-FIXES-JOURNAL.md (2026-07-17). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> commit-id:ec8e2093
5 tasks
jucor
added a commit
that referenced
this pull request
Jul 28, 2026
…ython math engine to math/
The Clojure implementation leaves the working tree; the Python engine
takes its place: `delphi/polismath/` -> `math/polismath/`, its own
installable package. `delphi/` keeps umap/narrative AND the whole math
test estate (tests, real_data + goldens, replay stores) and depends on
the engine editable (`[tool.uv.sources]`, one shared delphi/.venv —
`cd delphi && uv sync` as before).
Contents:
- DELETED: the Clojure tree (60 files) + test-clojure.yml. It stays in
git history as the certification oracle; math/README.md documents a
safe `git archive` restore recipe (excludes README, cleans only
untracked entries, jj-colocated caveats — never `git checkout -- math/`).
- MOVED: import name unchanged (`import polismath`); new
math/pyproject.toml (math-only deps, py.typed, `run-math-pipeline`
console script); poller entry -> `python -m polismath.poller`;
run_delphi.py invokes `python -m polismath.run_math_pipeline`;
math/.gitignore + math/.dockerignore added.
- PATH ANCHORS: new polismath/paths.py. Eight modules derived the delphi
root from `__file__` arithmetic that silently lands on math/ after the
move; all eight now assign their historical module-level names from
paths.py (test monkeypatching unchanged). certify's engine-tree cache
root became an explicit seam (_ENGINE_TREE_ROOT). paths.py documents
its editable-install-only contract (production modules must not import
it).
- ORACLE UX: certify works WITHOUT the oracle for cached pairs (manifest
input-keys match -> cache hit); a cache miss or --refresh raises
CertifyError "clj-oracle" with the restore recipe. The harness's
PyPollerRunner launches `python -m polismath.poller` (the old script
path is gone) — pinning tests updated. requires_math_tree guards skip
oracle-dependent tests on this tree (5 tests).
- DOCKER: the engine source reaches the delphi image via a NAMED
ADDITIONAL BUILD CONTEXT (`mathsrc`) + `uv pip install --no-deps
/math-src`; all four compose build blocks declare it. The test stage
asserts the local polismath survived `.[dev]` resolution (supply-chain:
the PyPI name "polismath" is not ours and is never consulted).
- CI: python-ci paths cover math/** + both pyprojects; the dead
run_math_pipeline cp/coverage flag removed (it's a polismath submodule,
covered by --cov=polismath).
Suite: 1211 passed / 29 skipped / 44 xfailed / 2 xpassed (baseline
1171/22 + 44 new tests across Steps 1+4 − 5 oracle-guard skips).
MERGE GATE (after the Step 3 soak):
- [ ] ALL prod hosts' compose parses `additional_contexts` and BuildKit
is enabled — after_install.sh runs `docker-compose config` on EVERY
host type before the service branch, so an old binary breaks every
deploy, not just math (needs docker compose >= 2.17; upgrade
/usr/local/bin/docker-compose first if v1).
- [ ] python-ci green on this branch (workflow_dispatch).
- [ ] One verified `docker compose build delphi math-python` on a
BuildKit host/runner.
KNOWN/ACCEPTED: 10 pre-existing pyright argument-type hits in
certify.py/test_certify.py (byte-identical code pre-move — latent noise,
left alone to keep this PR pure reorg); delphi/docs historical narratives
still say delphi/polismath (docs pass queued post-cutover).
ROLLBACK: revert this PR — pure source reorg; no DB/runtime coupling
(the built image contains the same installed packages either way).
Nothing in Steps 2-3 depends on it.
Series: Step 0 = #2685. NOTHING merges without Julien's explicit go.
commit-id:45664936
This was referenced Jul 28, 2026
Draft
jucor
added a commit
that referenced
this pull request
Jul 28, 2026
…ython math engine to math/
The final tidy-up, and a pure file reorganization (no behavior change):
the retired Clojure source is deleted, and the Python math engine moves
out of delphi/ into the now-free top-level math/ directory as its own
Python package. delphi/ keeps the UMAP/narrative service and ALL the
math tests and datasets, and uses the engine as a library — one shared
environment, `cd delphi && uv sync` exactly as before, `import
polismath` unchanged everywhere.
Contents:
- DELETED: the Clojure source (60 files) and its CI workflow. It stays
in git history — and that matters, because it is the ORACLE: the
reference implementation the Python engine was certified bit-for-bit
against. math/README.md documents a safe way to restore it
temporarily if the engine ever changes and needs re-certification
(and warns against `git checkout` in this jj-managed repo).
- MOVED: delphi/polismath -> math/polismath, with its own
math/pyproject.toml declaring only the ~10 scientific/database
dependencies — no LLM/GPU stack. The poller service now starts as
`python -m polismath.poller`.
- PATHS: eight modules used to locate the datasets/tests directory by
counting parent directories up from their own file — after the move
that arithmetic silently points at the wrong tree. All cross-tree
path knowledge now lives in one module (polismath/paths.py);
everything else refers to it, and tests can still substitute paths
exactly as before.
- CERTIFICATION still works without the Clojure source present:
previously recorded reference outputs are used as-is from cache; only
actually RE-RUNNING the Clojure side asks for the restored tree, with
a clear error explaining how.
- DOCKER: the delphi image build now pulls the engine source from a
second directory (an "additional build context") — this requires
docker compose v2.17+ with BuildKit on every machine that builds; see
the merge gate. The test image also asserts after installing that the
LOCAL polismath is what got installed — there is an unrelated
"polismath" name on PyPI that must never be picked up.
- CI: the python workflow now watches math/** too; a dead
copy-and-coverage step for run_math_pipeline removed (it is an
ordinary module of the package now, already covered).
Tests after the move: 1211 passed / 29 skipped (baseline 1171/22, plus
44 new tests from Steps 1+4, minus 5 that now skip because the Clojure
tree is absent — by design).
MERGE GATE (after Step 3 has soaked):
- [ ] every prod host's docker-compose is v2.17+ with BuildKit — the
deploy script parses the compose file on EVERY host type, so one
old binary would break all deploys, not just the math host
- [ ] python CI green on this branch
- [ ] one successful `docker compose build delphi math-python` on a
BuildKit machine
Known and accepted: 10 pre-existing type-checker complaints in
certify.py / test_certify.py (identical code existed before the move —
left alone to keep this PR purely a move); some docs still say
delphi/polismath in historical narrative (docs pass queued).
ROLLBACK: revert this PR. Nothing in the database or in Steps 2-3
depends on it, and the built image contains the same installed code
either way.
Series: Step 0 = #2685. Nothing merges without Julien's explicit go.
commit-id:45664936
jucor
added a commit
that referenced
this pull request
Jul 28, 2026
…ython math engine to math/ The final tidy-up, and a pure file reorganization (no behavior change): the retired Clojure source is deleted, and the Python math engine moves out of `delphi/` into the now-free top-level `math/` directory as its own Python package. `delphi/` keeps the UMAP/narrative service and ALL the math tests and datasets, and uses the engine as a library — one shared environment, `cd delphi && uv sync` exactly as before, `import polismath` unchanged everywhere. ## What's in this PR - **Deleted:** the Clojure source (60 files) and its CI workflow. It stays in git history — and that matters, because it is the ORACLE: the reference implementation the Python engine was certified bit-for-bit against. `math/README.md` documents a safe way to restore it temporarily if the engine ever changes and needs re-certification (and warns against `git checkout` in this jj-managed repo). - **Moved:** `delphi/polismath` -> `math/polismath`, with its own `math/pyproject.toml` declaring only the ~10 scientific/database dependencies — no LLM/GPU stack. The poller service now starts as `python -m polismath.poller`. - **Paths:** eight modules used to locate the datasets/tests directory by counting parent directories up from their own file — after the move that arithmetic silently points at the wrong tree. All cross-tree path knowledge now lives in one module (`polismath/paths.py`); everything else refers to it, and tests can still substitute paths exactly as before. - **Certification** still works without the Clojure source present: previously recorded reference outputs are used as-is from cache; only actually RE-RUNNING the Clojure side asks for the restored tree, with a clear error explaining how. - **Docker:** the delphi image build now pulls the engine source from a second directory (an "additional build context") — this requires docker compose v2.17+ with BuildKit on every machine that builds; see the merge gate. The test image also asserts after installing that the LOCAL polismath is what got installed — there is an unrelated "polismath" name on PyPI that must never be picked up. - **CI:** the python workflow now watches `math/**` too; a dead copy-and-coverage step for `run_math_pipeline` removed (it is an ordinary module of the package now, already covered). ## Testing 1211 passed / 29 skipped (baseline 1171/22, plus 44 new tests from the flip PR and this one, minus 5 that now skip because the Clojure tree is absent — by design). ## Merge gate (after Step 3 has soaked) - [ ] every prod host's docker-compose is v2.17+ with BuildKit — the deploy script parses the compose file on EVERY host type, so one old binary would break all deploys, not just the math host - [ ] python CI green on this branch - [ ] one successful `docker compose build delphi math-python` on a BuildKit machine ## Known and accepted 10 pre-existing type-checker complaints in `certify.py` / `test_certify.py` (identical code existed before the move — left alone to keep this PR purely a move); some docs still say `delphi/polismath` in historical narrative (docs pass queued). ## Rollback Revert this PR. Nothing in the database or in the flip/decommission steps depends on it, and the built image contains the same installed code either way. Series: Step 0 = #2685. Nothing merges without Julien's explicit go. commit-id:45664936
…2677) The agent is already INSTALLED on every instance (launchTemplates.ts, inside the shared `usrdata()`), but the config download and `systemctl start` live only in the ollama user-data block. So only the GPU box publishes `mem_used_percent` — every other tier reports CPU, network and EBS but no memory. That matters because memory, not CPU, is the binding resource on these instances. A 30-day production baseline shows the math worker at 0.5% mean CPU and both delphi tiers at 0.6-1.3%, so CPU says only "idle"; without memory there is no evidence on which to right-size them. This moves the agent's config-and-start block from `ollamaUsrData` into the shared `usrdata()`. Effect: every instance publishes `mem_used_percent` and `disk used_percent`. The `nvidia_gpu` section of the config collects nothing where there is no GPU, so the ollama box is unaffected. Safety notes, both deliberate: * All five launch templates already use the same `instanceRole`, and `cwAgentConfigAsset.grantRead(instanceRole)` is already granted — so the S3 fetch is authorised everywhere. Verified, not assumed. * `usrdata()` runs under `set -e`. The added commands are therefore guarded with `|| true` and `|| echo`: a metrics agent must never be able to abort an instance boot. This is the one place this deliberately differs from the ollama block it is derived from, which is unguarded. Not included on purpose: no change to the agent config JSON, no new metrics, no change to collection interval. This turns on what is already configured. Verified before submitting: * `npx tsc --noEmit` exits 0, before and after. `usrdata` is declared above `cwAgentConfigAsset` but only CALLED below it, and TypeScript does not object. * `npx cdk synth` succeeds with no AWS credentials. * In the synthesized CloudFormation, all five launch templates contain `systemctl start amazon-cloudwatch-agent` exactly once, each guarded. Ollama gets it once, not twice — the move is not a duplication. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The euro AWS account (954495807218) was torn down on 2026-09-07: it had been running a GPU instance, an RDS instance, load balancers and a NAT gateway with no application instances and zero database connections for weeks (~$758/mo). Remove the deploy plumbing so nothing can recreate or deploy to it: - scripts-euro/ and appspec-euro.yml (CodeDeploy bundle for eu-central-1) - deploy-euro job in deploy-alpha-aws.yml - deploy-static-euro job in deploy-prod.yml (euro.static-assets.pol.is) The euro.static-assets.pol.is bucket and a final RDS snapshot were kept. Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…gression reference Adds `delphi/docs/MATH_ALGORITHM_HISTORY.md` — a git-archaeology reference of the core Polis math algorithms (comment routing, PCA/projection, extremity/repness, clustering, consensus/moderation): when each was introduced, every edit that changed numeric output, and known bugs, with verified commit hashes and dates. It includes the 2025 comment-routing regression (#1961, fixed in #2611), the pre-2018 uniform-random routing history, and a guide to mapping conversations to algorithm versions via the production deploy timeline. Sourced entirely from public git history and code — no production data. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> commit-id:31321c47
…ect_cmnts (D1b) ## What was wrong `pca_project_cmnts` — the function that projects each comment into the PCA opinion space to measure its extremity — computed `coefs = -scale * (1.0 + center)`, a literal, untranslated copy of the Clojure engine's synthetic vote value `-1` (`pca.clj:167-178`). Clojure stays in the raw-Postgres vote convention (AGREE = -1) throughout, so `-1` is correct there. Delphi (the Python math engine) flips votes to its own convention at the Postgres ingress (`postgres_vote_to_delphi`) and fits PCA on AGREE = +1 data, so the untranslated `-1` INVERTS comment extremity: the correct magnitude is `scale*|1-center|` but the code produced `scale*|1+center|` (equal only at `center == 0`). A near-unanimous-AGREE comment (`center -> +1`) read as maximally extreme (`2*scale`) instead of ~0; a near-unanimous-DISAGREE comment (`center -> -1`) read as ~0 instead of maximal. ## The fix `coefs = scale * (AGREE - center)` with `AGREE = +1` (from `utils.general`) — the faithful Delphi-convention port of Clojure's synthetic-AGREE projection. The docstring is rewritten to document the convention translation. ## Testing (TDD, RED -> GREEN) - Replaced the tautological `test_pca_project_cmnts_formula` (it re-derived the implementation's own buggy formula) with one that derives expected values independently from the AGREE constant. - Added a behavioral sign test: unanimous-agree -> extremity 0, unanimous-disagree -> maximal. - Added an integration test spying on the extremity `E` reaching `priority_metric` through `_compute_comment_priorities`, pinned to hand-derived values 0 and `2*sqrt(2)`. This works despite the #2571 mirror (`priority_metric` deliberately reproduces a Clojure bug by returning `META_PRIORITY**2`) because the test inspects the argument, not the return value. - Added a provenance comment in `regression/utils.py`: the regression CSVs are pre-flipped to Delphi convention by `server/src/report.ts` (~line 393, `String(-row.vote)`), so the regression path must NOT re-flip. Full delphi suite: 406 passed / 17 skipped / 47 xfailed / 0 failed. ## Impact Output-inert today: `priority_metric` still returns `META_PRIORITY**2` (the #2571 Clojure bug-mirror), so extremity affects no DynamoDB output yet and no golden snapshots move. This is the extremity/PCA-parity groundwork that fix D12 (un-mirroring `priority_metric`) is blocked on. Distinct from fix D1 (`align_pca_signs`, which handles temporal ±eigenvector stability). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> commit-id:a6494735
…history (no DISTINCT ON dedup) commit-id:970c79cf
…er, Python driver, store, step comparer, CLI commit-id:2324207e
…k warm-start scaffolding (PR-A)
## What
Introduces `POLISMATH_ENGINE_MODE`, the engine-mode switch that will select between Clojure-parity warm-start behavior (`clojure-legacy` — the setting that reproduces the old Clojure engine's tick-to-tick behavior exactly) and the current cold-recompute behavior (`improved`, the default). This commit only adds the flag and the plumbing to capture the previous tick's state (a tick = one incremental math recompute over newly arrived votes); no behavior changes yet — the follow-up commits PR-B and PR-D consume the captured state.
## Changes
- New `polismath/utils/engine_mode.py`: `ENGINE_MODE_*` constants + `resolve_engine_mode()`, reusing `pca._resolve_impl_flag` (`pca.py:37-57`) so resolution rules match the PCA-solver switch. Default `improved`.
- `Conversation.__init__` gains cold-default fields `group_clusterings={}` and `group_k_smoother={}` (the Clojure engine threads these on the conversation object across ticks, `conversation.clj:433-484`; NOT persisted, per `conv_man.clj:52-74`).
- `recompute()` captures `prev_pca` / `prev_group_clusterings` / `prev_group_k_smoother` from the deepcopied `result` BEFORE the compute steps overwrite them (mirroring the Clojure fnks that read the incoming conversation: `conversation.clj:385, 457`) and threads them into `_compute_pca` / `_compute_clusters` as optional params (unused in this commit).
## Testing
TDD RED evidence (before implementing `engine_mode.py`): `tests/test_engine_mode.py` collection error — "ModuleNotFoundError: No module named 'polismath.utils.engine_mode'".
Cold-start invariance guard: a single-shot pipeline run on the small `vw` test conversation dataset is byte-identical under both modes (first-tick warm-start state is empty, so the modes must coincide). This guards PR-B and PR-D against drifting on the cold path.
Tests: 8 new (7 flag-resolution + 1 cold invariance). Full suite 414 passed, 17 skipped, 47 xfailed (was 406/17/47).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
commit-id:d61d82ad
…ode (PR-B) ## What When `POLISMATH_ENGINE_MODE=clojure-legacy` (the engine setting that reproduces the old Clojure engine's behavior exactly), the previous tick's unit PCA components are threaded into the power-iteration PCA as start vectors, matching Clojure (`conversation.clj:381-387` passes `:start-vectors (get-in conv [:pca :comps])` into `powerit-pca`, `pca.clj:86-105`; new columns are absorbed by 1-padding, `pca.clj:46-49`). Default `improved` mode is unchanged (cold recompute). ## Changes - `pca_project_dataframe` gains `start_vectors` (default `None`) and `require_powerit` (default `False`). With both absent, the code path is BYTE-IDENTICAL to before. When a warm start is supplied — or required by legacy mode — and `POLISMATH_PCA_IMPL=sklearn` is set, we warn and fall back to power iteration: sklearn's SVD has no start-vector hook, so running it would silently drop the warm start (documented at the guard). - `powerit_pca` already supported `start_vectors` + 1-padding + all-zero -> `None`; this just wires the production callers to it. - `Conversation._compute_pca`: in legacy mode sets `require_powerit=True` and, when `prev_pca` has real components, `start_vectors` = the previous components (empty/cold -> `None`, which yields the deterministic random cold draw). `prev_pca` is captured in `recompute()` (PR-A, the previous commit in this series). ## Semantic judgment calls (integrator, please verify vs Clojure) - The warm start is POSITIONAL: previous components align to current columns by index, and new (appended) comments are 1-padded — exactly Clojure's behavior. Column removal/reorder would misalign, but Clojure is append-only here, and `_power_iteration` truncates a too-long start vector defensively (pre-existing, `pca.py:124-127`) where Clojure would error. - "Legacy implies powerit" is enforced via `require_powerit=True` even on the cold first tick, so an operator setting `PCA_IMPL=sklearn` + legacy still gets power iteration (with a warning). ## Testing TDD RED evidence (before implementing): `test_start_vectors_reach_powerit` — "TypeError: pca_project_dataframe() got an unexpected keyword argument 'start_vectors'"; `test_legacy_tick2_receives_tick1_comps` — "assert None is not None" (tick 2 ran cold; no components threaded). Tests: 8 new (start_vectors threading, sklearn-conflict warning, improved-mode byte-identity, two-tick legacy warm start via spy, angle stability, cold-tick cross-mode identity). Full suite 422 passed, 17 skipped, 47 xfailed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> commit-id:be96f1da
… mode (PR-D smoother) ## What Ports Clojure's `:group-k-smoother` (`conversation.clj:454-478`), which damps K (the number of opinion groups) so it only switches to a new best value after `:group-k-buffer` (= 4, `conversation.clj:154`) consecutive ticks agree. This applies only in `clojure-legacy` engine mode (the setting that reproduces the old Clojure engine's behavior exactly); default `improved` mode keeps the existing `best_k` logic bit-for-bit. ## Changes - New `polismath/pca_kmeans_rep/group_k_smoother.py`: a pure function `group_k_smoother_update(prev_state, silhouettes_by_k, buffer=4) -> (new_state, smoothed_k)`. Implements the exact Clojure rule, INCLUDING: - the HIGHER-k-wins tie-break: Clojure's `apply max-key ... (keys ...)` returns the LAST maximal argument, and array-map keys iterate ascending — so ties go to the higher k. `conversation.py`'s improved `best_k` uses strict `>` (LOWER k wins) and is left untouched. - the #2536 clamp (`conversation.clj:469-478`): a carried `smoothed_k` absent from the current clusterings falls back to `this_k`, so `smoothed_k` is always a valid key and downstream never KeyErrors. - first-tick acceptance of `this_k` (when `smoothed_k` is `None`) — the cold-start invariant that keeps the two modes identical on tick 1. - Only the top-level smoother is ported; Python has no subgroups (`subgroup_clusters` is hardcoded `{}`), so the parallel subgroup smoother (`conversation.clj:520-560`) is intentionally omitted. - `Conversation._compute_clusters`: in legacy mode computes `silhouettes_by_k` from `group_clusterings`, runs the smoother against `prev_group_k_smoother` (threaded via `recompute()`, PR-A), picks `group_clusters = group_clusterings[smoothed_k]`, and stores the new smoother state + per-k clusterings on the conversation object (NOT persisted — `conv_man.clj:52-74`). Improved mode: `selected_k = best_k`, and the new fields stay `{}` (inert). ## Semantic judgment call (integrator, please verify) The degenerate early-return paths in `_compute_clusters` (fewer than 2 in-conversation participants / fewer than 2 base clusters) do NOT touch the smoother state, so a degenerate tick preserves the prior in-memory smoother memory rather than resetting it; the clamp protects the next real tick. ## Testing TDD RED evidence (before implementing `group_k_smoother.py`): `tests/test_group_k_smoother.py` collection error — "ModuleNotFoundError: No module named 'polismath.pca_kmeans_rep.group_k_smoother'". Tests: 12 new — 10 pure-function (buffer counting, reset-on-change, brief alternation, clamp present/absent, first-tick, higher-k tie-break) + 2 pipeline integration (legacy no-flicker-then-switch-after-4 via a silhouette stub over 9 chained `update_votes` ticks: `smoothed_k == [2,2,2,2,2,2,2,3,3]`; improved mode leaves the smoother inert). Full suite 434 passed, 17 skipped, 47 xfailed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> commit-id:d4dbbfa9
…r (dev/replay.clj) + cross-language comparer shim commit-id:2b7f93f6
… start (PR-C)
## What
Ports the warm-start k-means that the Clojure engine threads across conversation-update ticks (`:last-clusters`, `clusters.clj:301-312` / `conversation.clj:403-445`) into a new module `polismath/pca_kmeans_rep/legacy_kmeans.py`, wired into the `clojure-legacy` engine mode (the setting that reproduces the old Clojure engine's behavior exactly) of `Conversation._compute_clusters`. Improved mode (the default) is byte-for-byte unchanged.
## Lineage semantics (all cited to `math/src/polismath/math/clusters.clj`)
- Cold init = first-k-distinct rows, ids `0..k-1` (`init-clusters`, `:55-65`), reusing the production `_get_first_k_distinct_centers` so cold init is identical to `kmeans_sklearn`'s first-k init.
- `clean-start-clusters` (`:230-277`): safe-recenter drop-vanished + fallback (`:171-191`), uniqify identical centers (`:220-227`), most-distal split loop with new ids = `(inc max id)` (`:202-217, :267`).
- Merge keeps the LARGER cluster's id, tie -> later argument (`merge-clusters`, `:194-199`).
- `cluster-step` drops empty clusters, ties -> later cluster (`:142-158`).
- `same-clustering` sorts centers and zip-TRUNCATES to the shorter list (`utils/zip`), reproduced deliberately (`:68-76`).
## Wiring (`conversation.py`)
- Base level: `legacy_kmeans` warm-started from `prev.base_clusters` (base-iters = 100). Base-cluster ids are stable across ticks; new participants get strictly larger ids.
- Group level: per-k `legacy_kmeans` over base-cluster CENTERS, weighted by base-cluster member counts (`:weights base-clusters-weights`, `conversation.clj:444`), warm-started from the previous per-k clusterings. Clojure passes the MISNAMED `:cluster-iters` key that kmeans ignores, so the group level runs kmeans' DEFAULT max-iters (20), not group-iters (100) — matched here.
- `self.group_clusterings` now holds id-carrying cluster dicts `{id, members, center}` in legacy mode (it was the `(labels, centers, member_lists, silhouette)` tuple), so the next tick can warm-start from it. Improved mode never writes it (stays `{}`).
- `recompute()` threads `prev_base_clusters` alongside the existing `prev_*` state.
## Cold-start invariance (HARD GATE)
MEASURED on the small `vw` test conversation dataset (67 in-conversation participants -> 67 singleton base clusters; groups k=2..5): legacy cold clustering is STRUCTURALLY bit-identical to improved at BOTH levels — same base/group memberships, ids, counts, and all downstream repness (representativeness stats), priorities, and group-votes. Cluster CENTER coordinates differ only at floating point (~1e-13: the ported weighted mean via `np.average` vs sklearn's centroid on identical memberships). The existing `test_engine_mode` cold-identity test is adjusted to compare numbers with a 1e-6 tolerance and everything else exactly, plus a new belt-and-braces test that drops center coordinates and asserts EXACT structural equality.
On near-duplicate projections (a degenerate synthetic set), legacy and improved DIVERGE at the base level too: legacy keeps each near-duplicate as its own exact-init singleton (Clojure-faithful) while sklearn's Lloyd collapses a pair and leaves an empty cluster. Base cold identity therefore holds only when projections are distinct (as on `vw`), and legacy is the more Clojure-faithful side.
## Testing
RED evidence (before this commit): `tests/test_legacy_kmeans.py` — ModuleNotFoundError (module absent); `tests/test_base_cluster_lineage.py` warm-start-threading tests — `legacy_kmeans` never called (0 calls), `group_clusterings` stored a tuple rather than id-carrying dicts, and a new participant received id 0 (no lineage) instead of a larger id.
GREEN: 22 unit tests (hand-derived from the Clojure rules) + 8 integration tests (incl. `vw` cross-mode cold identity) pass; full suite 511 passed / 17 skipped / 47 xfailed (was 480/17/47), zero regressions.
## Not ported
The agg-bucket-votes unknown-pid edge case (`conv_edge_cases_test.clj:110-127`) is not applicable — Python base-cluster members are always the current in-conversation participant ids (a subset of `rating_mat.index`) and `_compute_group_votes` already skips unknown pids defensively (`conversation.py:1481-1486`).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
commit-id:ca2af3c7
…(PR-E)
## What
Ports the two Clojure `:in-conv` steps the Python pipeline was missing (`conversation.clj:243-269`) into the `clojure-legacy` engine mode (the setting that reproduces the old Clojure engine's behavior exactly) of `Conversation._get_in_conv_participants`. "In-conv" is the set of participants included in clustering. Improved mode (the default) is unchanged: threshold set only, no carry, no greedy floor.
In legacy mode, every tick now does:
1. CARRY: unions the vote-threshold set (>= `min(7, n_cmts)` votes) into the PERSISTENT in-conv set carried on the conversation object (`(or (:in-conv conv) #{})`, `conversation.clj:247`), so a participant, once in, never leaves.
2. GREEDY FLOOR: if fewer than 15 participants are in, greedily admits the top `15 - n` remaining participants by vote count descending, INCLUDING below-threshold voters (`conversation.clj:259-268`), and persists them.
The persisted set feeds base clustering (it decides which matrix rows are clustered) and is serialized as `:in-conv` in the JSON result blob (`to_dict`), matching the clustered rows.
`self.in_conv` is threaded in-memory across `update_votes` (via the deepcopy in `recompute`), NOT persisted to DynamoDB — same lifetime as the PCA/smoother/cluster warm state.
## Greedy tie rule (flagged for the integrator)
Clojure sorts a hash-map with `(sort-by (comp - second))`, whose order among EQUAL vote counts is hash-map iteration order — inherently non-deterministic. We break ties by matrix ROW ORDER (`user-vote-counts` insertion order) via a STABLE sort — a deterministic, reproducible surrogate for that underspecified Clojure tie case.
## Testing
RED evidence (against the PR-C HEAD, before this commit): `test_legacy_greedy_fills_to_fifteen` — got 2 (threshold only) != 15; `test_legacy_greedy_tie_break_is_row_order` — low-vote participants never admitted; the carry / `self.in_conv` tests — AttributeError (no `in_conv` field / no greedy).
GREEN: 9 new tests pass; full suite 520 passed / 17 skipped / 47 xfailed (was 511 after PR-C), zero regressions. The `vw` cold-identity gate stays green (`vw` has 67 in-conv participants > 15, so no greedy fires and the serialized `:in-conv` is unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
commit-id:a861cd87
…g write path in PostgresClient ## What The four math writers in `PostgresClient` (the functions that write math results to the Postgres tables the server reads) had zero live callers (verified: only commented-out references in `test_postgres_real_data.py`) and did not match the Clojure SQL. This rewrites them to the verified Clojure statements and fixes the write path so INSERTs actually commit. ## Writer fixes - `write_math_main`: `caching_tick = COALESCE((select max(caching_tick)+1 from math_main where math_env=?), 1)` upsert (`postgres.clj:323-338`). `math_env` is the column that keys each math result row — the server only reads rows matching its own setting. The production TypeScript prefetch (`pca.ts:84-151`) polls for `caching_tick > last`, so this is fidelity-critical. - `increment_math_tick`: atomic `INSERT ... ON CONFLICT ... math_tick+1 RETURNING` (`postgres.clj:292-295`), replacing the previous read-modify-write. - Add the `MathBidToPid` model + `write_math_bidtopid` (net-new; the server's `participants.ts` depends on it). - `write_participant_stats`: add the shared `math_tick` (Clojure writes all three data tables with one tick, `conv_man.clj:158-169`). ## Committing write path Add `_write_returning` (using `engine.begin()`) and route the four writers through it: `query()` uses `engine.connect()` (SQLAlchemy 2.0 commit-as-you-go), which rolls back INSERTs on close. Mocked unit tests could not catch this; the Postgres integration test did. `execute()` now commits too. ## Watermark reads for the poll loops Add `poll_votes_since` (`WHERE created > wm ORDER BY zid,tid,pid,created`, sign-flipped, `postgres.clj:132-145`) and `poll_moderation_since` (`postgres.clj:148-161`). Also add `ORDER BY zid,tid,pid,created` to `poll_votes` so the full-history rebuild reproduces Clojure's row-insertion order (base-cluster IDs seed k-means -> parity). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> commit-id:6324b652
… Clojure math-container replacement ## What The poller half of `MATH_POLLER_DESIGN.md` phase 1: a Python service that replaces the Clojure math container's production duties — vote/moderation watermark polling (a watermark is the timestamp up to which rows have already been processed), per-zid serialized compute (a zid is a conversation ID), and the four-table Postgres writes. ## Components - `polismath/poller/service.py`: `MathPollerService` — vote + moderation watermark loops mirroring `poller.clj:12-37` (strict `>`, max-of-batch advancement, `POLL_FROM_DAYS_AGO=10` boot window, allowlist/blocklist); per-zid engine chain `update_votes -> update_moderation -> recompute()` with load-or-init from `math_main` + full-history rebuild (`conv_man.clj:188-207` analog); error path dump -> one retry -> park (analog of Clojure's errorconv). - `polismath/poller/worker_pool.py`: per-zid FIFO + single-owner flag (strict serialization), `take-all!`/`split-batches` coalescing (votes before moderation), bounded cross-zid `ThreadPoolExecutor`. - `polismath/poller/math_writer.py`: one `math_tick` per cycle, shared across `math_main` / `math_bidtopid` / `math_ptptstats` (`conv_man.clj:158-169`); bidToPid positionally aligned with base-cluster ids ascending (`prep-bidToPid`, `conv_man.clj:35-40`; server `participants.ts:33-51`). - `scripts/math_poller.py` CLI (`--once` / run-forever, SIGTERM/SIGINT) + docker-compose service `delphi-math-poller` (profile `delphi-math`, shadow `MATH_ENV` by default) + `example.env` documentation. ## Testing `tests/poller/`: 44 unit tests (watermark, coalescing, serialization thread-safety, writer SQL incl. the `caching_tick` MAX+1 subquery + shared tick, bidToPid shape, allow/block, engine-mode passthrough, error path, load-or-init restoration boundary) + 1 opt-in integration test (throwaway `postgres:17` on port 5435): end-to-end poll -> compute -> write, shadow `math_env` isolation, shared `math_tick`, `caching_tick=1` on first write, restart-resumes. ## Cutover phasing Per `MATH_POLLER_DESIGN.md`, this ships SHADOW mode only: writes land under a `math_env` value the server does not read (`math_env` is the column that keys each math result row; the server only reads rows matching its own setting). The flip to serving traffic is gated on the blob-shape alignment work — the deltas between Python's `to_dict` and Clojure's `prep-main` catalogued by the H-B cross-language comparison (H-B = the replay harness's Clojure-side driver and comparer, commit 8 in this series). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> commit-id:18c7048f
…/utils/env_flags.py Moves the generic legacy-vs-improved env-switch resolver out of `pca.py` into `polismath/utils/env_flags.py` (public name `resolve_impl_flag`). `pca.py` and `utils/engine_mode.py` both import it from there, so reading `POLISMATH_ENGINE_MODE` no longer drags in the numpy/pandas `pca` import chain, and resolution warnings log under `polismath.utils.env_flags` instead of the pca logger. Resolution rules are unchanged: strip + lowercase, unknown value -> default with a warning, read at call time. `tests/test_env_flags.py` pins the rules, the shared-resolver identity, and the light `engine_mode` import (RED observed via ModuleNotFoundError before the move; the light-import test fails on the old code by construction). commit-id:bc0518d7
…ite + <2-participants edge (legacy mode)
## What
Ports the approved 2026-07-21 Opus review verdict covering quirks Q4 and Q5 from `delphi/docs/CLOJURE_QUIRKS.md` into clojure-legacy mode (the engine setting that reproduces the old Clojure engine's behavior exactly):
- Quirk Q4 (degenerate ticks still cluster): Clojure recomputes `:group-clusterings` EVERY tick unconditionally — `max-k-fn` is always >= 2 (conversation.clj:274-279) — so a tick with only 1 base cluster still runs k=2 kmeans on a single point. The clean-start initialization caps k at the number of distinct points, yielding 1 cluster carrying a lineage id; that degenerate value OVERWRITES `:group-clusterings`; and the recovery tick warm-starts from it, minting split ids via `(inc max-id)` (clusters.clj:267).
- Quirk Q5 (<2-participants edge): Clojure also has no guard for fewer than 2 in-conv participants (the set of participants admitted to clustering) beyond the truly-empty short-circuit — a 1-participant tick runs the full pipeline.
## How it works
clojure-legacy mode now skips both early returns and falls through to the normal legacy path, which reproduces all of the above exactly: the max_k arithmetic yields the k range [2]; `calculate_silhouette_sklearn` returns the 0.0 sentinel for singleton clusterings, matching Clojure's singleton rule (clusters.clj:350-353); and the advance of the group-k smoother (ported earlier in this stack as PR P6a) is preserved with the same `{2: 0.0}` input via the main path — the branch-local P6a advance block is removed as dead code. The 0-participant early return stays in both modes: it is unreachable past the empty short-circuit given the greedy in-conv floor from PR-E (#2623). improved mode (the engine setting that keeps Python's corrected behavior) keeps both guards byte-for-byte.
## Testing
TDD: `tests/test_degenerate_tick_parity.py` was RED on the old code for the pinned divergences (stale 2-cluster group_clusterings after a collapse tick; empty structures on a single-participant tick), GREEN after. Existing P6a smoother tests are unchanged and green (24 passed with `test_group_k_smoother.py`; 37 passed with the lineage / greedy-carry / pca-warm-start / engine-mode neighbor suites).
commit-id:bbb74f71
## What Adds `scripts/clj_timing_probe.py` (Spec C) — a click CLI that measures how long the Clojure engine takes at increasing conversation sizes, so replay runs can be sized to a wall-clock budget. It runs the Clojure Mode A replay driver (`math/dev/replay.clj`, which replays a recorded vote schedule through the Clojure engine) at increasing vote-count sizes, records wall-clock seconds and final-blob success per size, fits runtime ~ a + b*N^k by log-log least squares (with a, the fixed JVM-startup cost, estimated from the smallest run), and recommends the largest N that stays within the wall-clock budget. ## Testing TDD: `tests/replay_harness/test_timing_probe.py` mocks the clojure subprocess for all unit tests (CSV truncation, schedule shape, fit recovery on synthetic power-law data, recommendation math, stdout line budget, CLI end-to-end), plus one real-subprocess integration test gated on `clojure` being on PATH and `RUN_CLJ_INTEGRATION=1`. commit-id:96c1f19e
…ure-legacy mode (Q1) ## Why Quirk Q1 (`delphi/docs/CLOJURE_QUIRKS.md`): the Clojure math worker NEVER honored the participant ban (`participants.mod = -1`) — its ingest path has no mod filter, so banned participants keep influencing user-vote-counts, in-conv membership (the set of participants admitted to clustering), PCA, clustering, and repness (the per-group representative-comment statistics). Python's real ban feature (the `mod_out_ptpts` row drop in `_apply_moderation`, added 2026-06-10) is correct, but it is a certification divergence against Clojure. ## What clojure-legacy mode (the engine setting that reproduces the old Clojure engine's behavior exactly) now stores `mod_out_ptpts` without applying it: the rows are kept, and the single choke point — the `_apply_moderation` row drop — is gated on improved mode. Everything downstream then leaks exactly like Clojure. improved mode keeps the ban byte-for-byte. This supersedes the legacy-mode premise of #2623's T1 carry-prune test: banning can no longer shrink the legacy clustering pool, so the stale-carry trap cannot arise. The `vote_counts` intersection stays as belt-and-braces (comment updated), and the test now pins ban-invariance (`TestCarryUnderParticipantBan`). ## Testing TDD: `tests/test_mod_ptpt_leak_parity.py` — 4 legacy tests RED on the old code (rows dropped / not clustered / not counted / not in-conv), GREEN after; the improved-mode pin stayed green throughout. Neighbor suites green: greedy-carry + degenerate-tick (22 passed), discrepancy moderation subset (19 passed, 6 pre-existing data skips). commit-id:d4116474
…d py cache + parallel entries (Phase 0) ## Why `GOAL_CUTOVER_READY.md` Phase 0 (Julien ruling 2026-07-27): the certification battery re-ran the full ~36-minute Python re-replay after ANY `polismath` edit, blocking every code change. ## What - The Python recording cache key is now the ENGINE-scoped tree hash: pure-harness files (`certify.py`, `poller_equiv.py`, `prodclone.py`, `shard_bench.py`, `poller/**`) are excluded from the hash — none is reachable from the replay subprocess import graph (`scripts/replay_driver.py` -> driver/schedule/real_data/store/stepcompare/types -> engine). Manifest key renamed `py_tree_sha256` -> `engine_tree_sha256`; the resulting one-time invalidation of all 20 Python recordings doubled as the timed A/B run. - `run_battery(workers=N)` fans the per-entry heavy work (driver subprocesses + hash-first compare) across threads; the ledger fold stays strictly serial in battery order, so report + ledger are bit-identical to `workers=1` (pinned by test). Step-verdict cache writes are atomic (tmp + `os.replace`). CLI `--workers` defaults to 6. ## Measured A/B on the 10-core host - First pass: 19m17s wall vs ~36m serial (the long pole: `pakistan:uniform8` alone is ~18m). - Cached pass: 22s. - Harness-only edit: 2m9s with ZERO re-replays (previously: the full 36-minute re-replay). ## Testing TDD: 7 RED -> GREEN; replay_harness 464 passed / 5 skipped (baseline 456/5 plus exactly the 8 new tests). Battery on this tree: 20/20 MATCH x2 (post-invalidation re-record + cached pass). The Phase 1 `engine_mode` inventory (18 branch sites classified DELETE/PARK/KEEP) is journaled. commit-id:31c19ea5
… the only path (item 2 parked) ## What `GOAL_CUTOVER_READY.md` Phase 2, chunk C1 of the mode collapse — removing the engine's improved-mode branches so the Clojure-faithful behavior becomes the only code path (branch-site inventory in journal session 7). The engine now always runs the Clojure semantics on degenerate input: - `_compute_pca`: only a truly-EMPTY matrix short-circuits (Clojure runs real math on a 1x1 matrix — the every-vote step-0 oracle); the improved-mode <2 guard is deleted. - `_compute_clusters`: the <2-in-conv-participants early return and the <2-base-clusters early return (both improved-mode-only) are deleted — degenerate ticks fall through to the full base->group clustering chain exactly like `conversation.clj` (max-k always >= 2). - `conv_repness`: the <2 matrix guard is deleted — repness/consensus is computed at every size (the best-agree guarantee). ## Parking The deleted improved-mode guards are queue item 2 in `POST_CUTOVER_IMPROVEMENTS.md`; the `improvements/*` park commit — which preserves the deleted code as the reverse of this commit's guard hunks — is minted at the end of the collapse. Improved-mode guard tests are deleted with their branches (`test_degenerate_tick_parity.py`). Legacy tests unchanged and green. commit-id:13e86ed3
…ditional (item 4 parked) `GOAL_CUTOVER_READY.md` Phase 2, chunk C2a of the mode collapse (removing the engine's improved-mode branches so the Clojure-faithful behavior is the only code path). Every votes recompute now drops `last_mod_timestamp` (the moderation watermark) exactly like Clojure — quirk Q15: Clojure's `conv-update` graph has no `:last-mod-timestamp` node, so the watermark never survives a votes recompute (`conversation.clj:780-820`). The former improved-mode persistent watermark is queue item 4 in `POST_CUTOVER_IMPROVEMENTS.md` (its park commit, preserving the deleted code, is minted at the end of the collapse). The improved-mode watermark test is deleted with the branch. commit-id:4aa08bd4
… unconditional (item 5 parked)
`GOAL_CUTOVER_READY.md` Phase 2, chunk C2b of the mode collapse (removing the engine's improved-mode branches so the Clojure-faithful behavior is the only code path). Comment priorities now always read the PREVIOUS tick's stored group-votes — quirk Q2 — exactly like Clojure's `:comment-priorities` node shadowing with `(:group-votes conv)` (`conversation.clj:658`); `{}` on the first tick == Clojure's nil. The former improved-mode current-tick read is queue item 5 in `POST_CUTOVER_IMPROVEMENTS.md` (its park commit, preserving the deleted code, is minted at the end of the collapse). The improved-mode priority test is deleted with the branch.
commit-id:ac5c5903
…A + legacy kmeans as the only solvers (item 8 parked) ## What `GOAL_CUTOVER_READY.md` Phase 2, chunk C3 of the mode collapse (removing the engine's improved-mode branches so the Clojure-faithful behavior is the only code path). The engine's solver paths are now unconditionally the Clojure-faithful ones: - `_compute_pca`: always warm-starts power iteration from the previous tick's components (Clojure's `:start-vectors`, `conversation.clj:385`), with `require_powerit=True`. - `_compute_clusters`: the base level always runs `legacy_kmeans` with lineage warm start (PR-C of this stack); the group level always runs the per-k legacy loop with the group-K smoother (PR-D). The sklearn cold-recompute arms (`kmeans_sklearn` at the base level + `best_k` group selection) are deleted; dead `GROUP_ITERS`/`base_weights`/`legacy_mode` cleaned up. - `POLISMATH_PCA_IMPL` (the env var selecting the PCA implementation) is left in `pca.py` but is now ENGINE-INERT: with `require_powerit` always True, a sklearn selection is always overridden back to powerit (the existing warn+fallback). Full removal ships with queue item 8 post-cutover — deleting it now would cascade through 9 test files for zero behavior change (scope ruling, journal session 7). ## Tests Improved-mode pins deleted with their branches: improved-tick2-cold, cross-mode cold-tick equality (PCA warm start), improved-never-calls-legacy-kmeans + cross-mode vw invariance (lineage; replaced by a legacy determinism pin), and 3 smoother-inert tests. 51 tests green across the affected files. The deleted sklearn arms are queue item 8 in `POST_CUTOVER_IMPROVEMENTS.md` (park commit at the end of the collapse). commit-id:30038853
…branches (ban filter, tally sources, blob shape) ## What Phase 2, chunk C4 of `GOAL_CUTOVER_READY.md` (the standing goal document for making the Python math engine ready to replace the Clojure one in production). The "mode collapse" removes the engine-mode switch that selected between clojure-legacy behavior (reproducing the old Clojure engine exactly) and improved behavior; this chunk executes the delete-only branches — the ones not parked for later re-landing (inventory in journal session 7). After it, `conversation.py` and `pca_kmeans_rep/` are entirely `engine_mode`-free (grep: 0 hits). ## Deletions — the Clojure-exact behavior is now the only path - Quirk Q1 ban filtering deleted outright. Q1 is the quirk that the Clojure engine never honored participant bans (`participants.mod = -1`); Julien ruled that bans are not a Polis feature, so improvement-queue item 1 is dropped rather than parked. `rating_mat` is now a raw copy of the vote matrix, and the banned-participant set (`mod_out_ptpts`) is still ingested but has no effect — exactly like the Clojure worker. - Repness `tid_order` (the comment ordering in the representativeness output): always the arrival-order tie-break, i.e. ties resolve by first-vote arrival order. - Group-votes tally source: always `raw_rating_mat` (3 call sites). - In-conv — the set of participants counted as "in the conversation" for clustering: always the carry + greedy-floor rule; the improved threshold-only arm is deleted. The JSON result blob (`math_main.data`) serializes the persisted in-conv set when present. - Blob `votes-base`: always the Clojure-exact per-bucket vote vectors. - Group-aware consensus: always the every-group Laplace product, where zero-vote groups contribute the smoothed factor 1/2 (divergence fingerprint FP-b3670cb052). - `to_dict`: `_apply_legacy_blob_shape` runs unconditionally; `from_dict`: the watermark / arrival-order / sign / permutation restore runs unconditionally. - Repness totals: the "rest" comparison domain is always clustered voters only. ## Testing Deleted 18 improved-mode test pins across 6 test files (12 were failing against the collapsed engine, 6 passed only vacuously); 65+34 tests green on the affected files. commit-id:fe2b0bb2
…cs + poller flag plumbing deleted ## What Phase 2, chunks C5+C6 of `GOAL_CUTOVER_READY.md` (the standing goal document for the Python math cutover) — continuing the mode collapse, the removal of the engine-mode switch (`POLISMATH_ENGINE_MODE`, clojure-legacy vs improved) so only the Clojure-exact behavior remains. - Replay driver: the `mod_update` reducer semantics — a moderation update triggers a votes-recompute first and updates only the moderation sets and watermark, with the effect landing on the next tick — is now the only moderation path. Deleted: the improved-mode `update_moderation` path, its cumulative `mod_state`, the emptying-transition guard (`_guard_moderation_clear` / `_mod_dict`), and the `NotImplementedError` raised on improved mode + restart. - Poller service (the service that polls Postgres for new votes and runs math ticks): the `engine_mode` config field, the `apply_engine_mode` passthrough, and the env import and startup-log field are deleted — the poller no longer reads or writes `POLISMATH_ENGINE_MODE`. `poller/__init__` and `env_flags` docstrings updated. - `utils/engine_mode.py` itself survives until the harness purge in the next commit: `certify.py` / `store.py` / `poller_equiv.py` still import it. ## Testing Deleted the 3 driver tests and 2 poller tests that pinned the removed paths. 21 driver + 93 poller tests green. commit-id:a4c818c8
…purge + flag machinery deletion ## What Final chunk of the `GOAL_CUTOVER_READY.md` Phase 2 mode collapse — the removal of the engine-mode switch between clojure-legacy (the setting that reproduces the old Clojure engine exactly) and improved behavior. The goal doc's DONE-gate grep (`ENGINE_MODE|engine_mode|resolve_engine_mode` over `delphi/polismath/`) now returns ZERO hits. ## Changes - `certify.py` (the certification-battery harness): `BatteryEntry` / `derive_schedule_id` / `parse_battery_entry` drop the mode. Schedule ids keep their historical `-clojure-legacy` suffix via the frozen `_LEGACY_SUFFIX` literal, and fingerprints bake the same literal into the digest — so recording directories AND every historical key in `divergences.json` (the ledger of observed Python-vs-Clojure divergences) stay valid. The manifest drops its mode key, riding the same Python-side re-record that the engine change already forced. `run_py_driver` no longer sets any env; `compare_recording_pair` drops its dead mode parameter; battery reports and ledger observations drop the mode field (historical ledger values keep theirs on disk). - `poller_equiv.py` (the live Python-vs-Clojure poller equivalence harness): env plumbing deleted (`build_py_env` / `PyPollerRunner` / `EquivConfig`). `store.py` provenance drops the env record. - `polismath/utils/engine_mode.py` DELETED; `scripts/certify_battery.json` entries drop the key. - Test sweep: engine-mode imports, fixtures, and setenv calls removed across 20 test files; `test_engine_mode.py` and the two env-guard conftest fixtures deleted; 9 default-mode tests re-pinned to the now-only legacy semantics (arrival-order tid export, inert bans, bucket votes-base, the 1x1 real-math edge case, base-cluster-id (bid) group members, greedy-floor-aware D2c); the D9/D10 comparisons on the vw dataset's cold-start variant now XPASS (parity improved). ## Testing Full suite: 1155 passed / 22 skipped / 44 xfailed / 2 xpassed. commit-id:7f42df81
…e executed, battery 20/20 Session 7 wind-down for `GOAL_CUTOVER_READY.md` (the standing goal document for the Python math cutover). `GOAL_STATE.md` is rewritten: Phases 0-2 done; next up are reviews triage, the clarity refactor, goldens + gates, and the EC2 measurement. The journal carries the Phase 0 A/B data, the Phase 1 inventory, the Phase 2 execution record, and the post-collapse evidence from the certification battery (which replays 20 recorded dataset entries through the Python engine and compares against Clojure reference recordings): 20/20 MATCH on a full re-replay plus a cached pass. commit-id:29a11a32
…s + blob-injection pins ## What `GOAL_CUTOVER_READY.md` Phase 3, the clarity refactor (Julien ruling 2026-07-27: land the clean code PRE-cutover; spec in `HANDOFF_PR14_VECTORIZED_REFACTOR.md`). - 14c: `compute_group_comment_stats_df` is split into the plumbing (`_group_comment_vote_counts`: mapping, totals, cross-product, the `other_*` columns) and the statistics recipe (`_comment_stats_from_counts`: pseudocount probabilities, proportion tests on raw counts, representativeness ratios, two-proportion tests, signed metrics, the "repful" pick) — the recipe now reads like the scalar chain it replaced. Pure code motion: identical operations in identical order, with bit-identity guarded by the certification battery (which replays 20 recorded dataset entries through the engine and compares against Clojure reference recordings). - 14b: `TestBlobInjectionStats` injects the CLOJURE result blob's group memberships (unfolded through the blob's own base clusters) plus the dataset votes into the PRODUCTION stats path, and compares every repness entry in the blob per (gid, tid): n-success / n-trials / p-success / p-test / repness / repness-test / repful-for. Green on the `vw` AND `biodiversity` datasets (`repness-test` is compared at 2e-6 relative tolerance because Clojure emits it rounded). ## Also The three sub-threshold cleanups from the collapse-review agent: `CUTOVER_RUNBOOK.md` drops the stale engine-mode env line; the greedy-carry threshold test drops its now-duplicate `improved` parametrize label; two module docstrings updated to collapse-era wording. commit-id:acff8fbe
…il (prod-blob Q12) + s7 Phase 4 journal ## What Phase 4 of `GOAL_CUTOVER_READY.md` (the standing goal document for the Python math cutover): golden snapshots re-recorded at the collapse tree — the source tree after the mode collapse removed the engine-mode switch — following the verify-then-record protocol. Journal session 7 (continued) shows the drift is exactly the two expected legacy families, and the recording engine was oracle-certified 20/20 on the same datasets. ## Results - Comparer: 7/7 PASS. - `--include-local` suite (private datasets) green with ONE annotation: the FLI-cold_start repful comparison now xfails. The production blob for that dataset carries Clojure's unseeded cold-start PCA — quirk Q12 (#2661: the Clojure cold tick's PCA start vector is unseeded-random, so even two Clojure runs differ) — and the collapse made legacy kmeans the only cold-tick path, so the Python selections no longer intersect that random draw. The battery's pinned-cold-start FLI certification supersedes this comparison. - Battery: TWO consecutive 20/20 MATCH passes; the divergence ledger holds 81 entries, 0 open. commit-id:075d2f77
…synthesized 33k x 783 shape, cold+warm tick timing ## What `GOAL_CUTOVER_READY.md` Phase 5 / `CUTOVER_RUNBOOK.md` risk item 3: the pre-flip measurement tool — a benchmark answering whether the Python engine can tick the largest production-scale conversations serially before production is flipped onto it. ## How it works Synthesizes the largest conversation shape found in prodclone (the local clone of the production database): 33,422 participants x 783 comments, ~2.0M votes — seeded RNG, no real data. It then times one COLD tick (full PCA from scratch) and one WARM tick (lineage warm starts populated — the steady per-tick cost the serial-capacity verdict rides on). ## First numbers Smoke run at 2000 x 200 x 120k votes on an arm64 laptop: cold 6.5s, warm 14.4s — the warm tick is the expensive one at scale (the legacy kmeans warm-start path). The benchmark runs on the bench EC2 instance class via a plain clone; the dataset is synthesized on-instance. commit-id:5747f8c9
…measurement, equiv PASSes, NaN pin ## What Closing evidence for `GOAL_CUTOVER_READY.md` (the standing goal document for the Python math cutover), covering its numbered DONE conditions 4-6. - `CUTOVER_RUNBOOK.md` risk item 3 RESOLVED with the measured numbers (r8g.4xlarge EC2 instance, 33,422 participants x 783 comments, 2.0M votes): cold tick 519.6s, WARM tick 1856.0s (~31 min). Verdict: NOT serial-OK at the extreme shape — either the deterministic large-conversation path (runbook item 9) or a `POLL_BLOCKLIST` of the 7 historical zids (conversation ids) at that scale is required before they tick on Python. The flip itself is not blocked. - Journal: the battery pair plus zero-open-ledger evidence for condition 4 (the certification battery at 20/20 MATCH twice, divergence ledger 0 open); live equivalence full-run PASSes on the `vw` AND `pc-meta-02` datasets for condition 5; the EC2 + local measurement records for condition 6; review dispositions (the #2659 finding was pre-fixed by the mode collapse; #2663 is sound); and the Copilot-credits correction. - #2663 review pin applied: `test_euclidean_propagates_nan_instead_of_clamping` — NaN must propagate through the legacy `_euclidean` distance; the old `max(0.0, d2)` silently ate it — plus a ledger addendum for quirk Q11 (the legacy kmeans distance formula's cancellation makes near-coincident points tie at exactly 0.0, and those ties decide cluster merges). commit-id:c5632ace
## What
Replace the per-pair Python `_euclidean` scans in `legacy_kmeans.py` (the quirk-for-quirk port of the Clojure engine's k-means) with per-center BLAS distance columns — BIT-IDENTICAL outputs, certified twice by the full certification battery (which replays 20 recorded dataset entries through the engine and compares against Clojure reference recordings). The bit-identity plan ("Plan A") held: no tie-break divergence appeared, so no fallback was needed.
## Why
Hot paths (cProfile at 4000 participants x 300 comments x 240k votes, 112.5s total): `_euclidean` 26.4M calls / 61.8s cumulative; `np.array_equal` 8.0M calls / 38.9s cumulative via the O(n^2) `n_distinct_rows` scan; `cluster_step` 65.3s cumulative.
## Bit-identity engineering
Empirically probed on numpy 1.26.4 / OpenBLAS 0.3.23 arm64:
- REJECTED: `X @ c` (dgemv) — it reassociates relative to `float(np.dot(row, c))` for d>=4; `einsum` and `(m*m).sum(1)` differ even at d=2. Last-ulp differences are amplified by quirk Q11 — the legacy distance formula d^2 = |a|^2+|b|^2-2ab cancels catastrophically, so near-coincident points tie at EXACTLY 0.0 — into changed 0.0-ties, i.e. changed cluster lineage.
- SHIPPED: batched matmul — `(n,1,d)@(n,d,1)` for row norms and `(n,1,d)@(d,1)` for cross products — bit-equal to `float(np.dot(...))` on all 85 probed shape/scale combos (n=1..33422, d=1..783, scales 1e-8/1/1e8, C and F memory order), including the vw dataset's knife-edge pair (both distances EXACTLY 0.0) and NaN propagation.
- The column combine keeps the scalar code's exact order and associativity: `(row_norms + |c|^2) - 2.0*cross`; the floor is `np.where(d2 < 0.0, 0.0, d2)` so NaN propagates (never a maximum-clamp); same IEEE sqrt.
## Changes (`legacy_kmeans.py` only)
- New `_row_norms` + `_euclidean_col` (bit-identical distance columns).
- `cluster_step`: the columns are folded in the existing Clojure scan order (hash order for >8 clusters, input order for <=8) with the scalar rule `d <= best` (ties go to the LATER cluster; NaN never wins); members are regrouped by ascending row index, which equals the scalar append order.
- `most_distal`: same inner fold; exact outer last-wins reduction (a NaN first row sticks; later NaN rows are skipped; otherwise last argmax).
- `n_distinct_rows(bound=)`: vectorized elimination passes with `array_equal(..., equal_nan=True)` semantics; `clean_start_clusters` passes `bound=k` so `min(k, .)` stays exact without the O(n^2) count.
- `_recenter_center`: index-gather (same values, same mean).
- `_euclidean` / `same_clustering` / `weighted_mean` semantics unchanged.
## Testing (TDD)
RED = 4 ImportError pins (`_euclidean_col` / `_row_norms`) plus a TypeError pin for the `bound` kwarg. 11 new tests: bit-equality against a VERBATIM `_scalar_d2_reference` copy of the pre-vectorization formula (exact ==), the knife-edge 0.0 case, NaN propagation, `cluster_step` / `most_distal` equivalence against verbatim scalar reference loops (grid-tie fixtures, the >8 and <=8 scan orders, weights, k>n, NaN), and bounded-distinct semantics (NaN, -0.0 == 0.0).
## Results
- Full suite: 1171 passed / 22 skipped / 44 xfailed / 2 xpassed (baseline 1160 + 11 new; zero new failures). Pyright clean.
- Battery: certify 20 entries — MATCH=20, DIVERGENCE=0, SKIPPED=0, ERROR=0 — TWICE (on the vectorized tree; final bytes after an annotation-only cleanup).
- Bench at 8000 x 400 x 480k votes: cold 68.53s → 3.58s (19x), warm 159.53s → 3.77s (42x).
- Bench at the FULL 33,422 x 783 / 2.0M-vote shape: cold 28.15s, warm 26.66s — versus ~31 min warm before (~70x); the largest production conversation now ticks in under 30 seconds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
commit-id:52a753ee
…ath-python, env MATH_PYTHON_ENV ## What Julien ruling (session 7): the Python math poller is the math ENGINE, not part of Delphi/UMAP (the separate topic-modeling and narrative service) — its naming should say so. - Compose service `delphi-math-poller` → `math-python`; compose profile `delphi-math` → `math-python`. - Env var `DELPHI_MATH_ENV` → `MATH_PYTHON_ENV`, and its default value for `math_env` — the column that keys each math result row; the server only reads rows matching its own setting — changes from 'delphi' to 'python'. The default change is free: no rows exist anywhere yet. - Living docs updated: `CUTOVER_RUNBOOK.md` step 1 plus a prod deploy-reality note (`after_install.sh` starts services BY NAME per role, compose profiles gate dev only, prod tracks the `stable` branch); `MATH_POLLER_DESIGN.md` §4; the `example.env` block. ## Testing Compose validates with and without the profile. commit-id:fc71fced
…parison, verdict serial-OK DONE condition 6 of `GOAL_CUTOVER_READY.md` (the EC2 performance measurement) finalized. Same r8g.4xlarge instance, conversation shape, and seed as the pre-vectorization run: cold tick 519.6s → 29.0s (~18x), warm tick 1856.0s → 26.6s (~70x). - `CUTOVER_RUNBOOK.md` risk item 3 now carries the final verdict — serial OK at every observed conversation shape; no blocklisting (none needed); item 9b (seeded sampled PCA for extreme shapes) is now optional — plus the historical measurement chain. - `GOAL_STATE.md` condition-6 line updated. - Final battery pair on this tree: the certification battery (20 recorded dataset entries replayed and compared against Clojure reference recordings) at 20/20 MATCH, twice. commit-id:9547faa9
…queue freshness audit ## Rename stragglers The four stale 'delphi' values for `math_env` — the column that keys each math result row; the server only reads rows matching its own setting — that the #2680 review caught are now 'python': the compose comment, `MATH_POLLER_DESIGN.md` §4, `CUTOVER_RUNBOOK.md` step 2 (which contradicted step 1 mid-runbook), and the `poller/__init__` docstring. ## Doc freshness audit (Julien request) - Improvement-queue item 9 marked (a) DONE (PR #2679: ~70x speedup, bit-identical, verdict serial-OK) with (b) left optional; item 10 marked DONE (PR #2673); item 12 added (persist warm-start state across restores — restart-induced K flips). - The queue header now points at the `improvements/*` park bookmarks (the jj bookmarks holding the deleted improved-mode branches for possible later re-landing). - The row for quirk Q11 — the legacy kmeans distance-cancellation quirk whose exact-0.0 ties decide cluster merges — gains the vectorization addendum: ties preserved bit-exactly; the dgemv/einsum kernels were rejected. - Quirks-row "legacy mode" phrasings are left as history — the preamble declares the mode collapse globally. commit-id:98853895
…t for the cutover-PRs session Self-contained handoff document — the entry point for the next session, which executes the actual cutover PRs. It carries: - The state summary: goal DONE, with evidence pointers. - The two OPEN RULINGS awaiting Julien: shadow vs replace (run the Python engine alongside the Clojure one first, or replace it outright), and the flip mechanism. - Per-PR specs: S0 (land + promote to `stable`), S1 (shadow wiring, including the Secrets-Manager environment reality), S2 (the flip), S3 (decommission). - The hard-won gotchas: the spr commit-id trailer rules, the git-checkout trap in the jj-colocated repo, Copilot review credits being exhausted, compose profiles gating dev only, and the battery cost model. - The post-cutover adjacent tasks. commit-id:f2c58d93
…estamp Duplicate (pid, tid) resolution in Conversation.update_votes kept the LAST vote in payload order. A batch retried at the queue tail arrives AFTER a newer revote queued during the failure, so payload order no longer implies temporal order and an older vote could win with a current-looking timestamp (P-019 M2). Carry `created` into the vote-update tuple and stable-sort by it before drop_duplicates(keep='last'), so later-vote-wins holds regardless of arrival order. For an already-time-sorted stream (the replay/certification input) the stable sort is an identity, so certified outputs are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
M1 (park/unpark loses failed votes): after retry exhaustion the zid was
parked and the stale cached conversation left in place, so the interval that
failed stayed missing until an unrelated eviction/restart. Recovery now:
- `_unpark` invalidates the cached conv so the next batch rebuilds from the
full authoritative vote history in Postgres (which still holds the lost
interval), subsuming both the lost and the new votes.
- a periodic reconciler (`_reconcile_once`, MATH_POLLER_RECONCILE_INTERVAL_MS,
also run in poll_once) recovers a zid that failed and then received NO
further votes, via a new REBUILD worker-pool message that forces a
full-history reload even with an empty batch.
M2 (retry re-advances temporal state): `_run_engine` now writes BEFORE
caching, so a write failure leaves the cache holding the last-good persisted
state rather than an unpersisted update. A retry then re-derives cleanly
instead of double-applying the batch on top of itself.
Tests drive the REAL service + worker pool with lightweight fakes and assert
FINAL VOTE CONTENTS: A recovered after unpark; A recovered by the reconciler
with no new vote; newer revote wins when the older write fails; a
fail-then-succeed retry applies the batch exactly once.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
Two cache-key gaps let a fresh run be compared against a stale recording and
report its old MATCH (P-019 M3):
- The PYTHON recording manifest omitted the comments CSV, though Python
loads moderation events from it. A comments-only mutation left the py
recording cached. `ensure_py_recording` now keys on comments_csv_sha256,
mirroring the Clojure side, and the call site forwards the CSV.
- `canonical_schedule_hash` omitted `restart_after` (the restart seam) and
the `clojure` warm-start options, so editing a schedule from no-restart to
restart under the same schedule_id reused both stale recordings. Both are
now folded into the hash.
Bumped a new `_RECORDING_MANIFEST_VERSION` (=2) into both manifests to
invalidate every existing cached recording once. Tests: comments-only
mutation misses the py cache; two schedules differing only in restart_after
(or clojure options) hash differently; schedule_id/notes still ignored.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
…hards
Unbounded cache is not acceptable for a long prod shadow soak, and the
cache-cap / shard env vars never reached the container (P-019 M4):
- PollerConfig.conv_cache_cap now defaults to a FINITE 200 (0 = unlimited
remains, but must be set explicitly); a negative cap is rejected at
construction (it would pop a just-inserted conv forever).
- docker-compose math-python now passes MATH_CONV_CACHE_CAP (default 200),
POLL_SHARD_INDEX/POLL_SHARD_COUNT, and the reconciler interval, all
documented in example.env.
Tests: default cap is finite (direct + from_env); 0 allowed; negative
rejected (direct + from_env).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
…e note Update the canonical handoff per the P-019 review's documentation nits: - Replace "bit-exact / Clojure-exact" with the tolerance-based reality (oracle rerun, canonical-hash-or-tolerant compare, dropped subgroups). - Fix the stale "nothing is on edge yet" claim. - Add the M1-M4 fix summary and the accepted-M5 release note (participant bans no longer applied to the report engine; full-PCA large-conversation "mode collapse" vs Clojure is expected, not a defect). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s
…thropic Batch API (#2698) * delphi: topic naming via Anthropic Batch API (provider-agnostic) Replace the hardwired ollama.chat topic-cluster naming with a provider-agnostic path. Topic naming for a layer now collects all cluster prompts and, when the provider supports batching (Anthropic), submits ONE Message Batch and polls until it ends; Ollama keeps the one-by-one path for self-hosters. - New umap_narrative/topic_naming.py: importable without torch, holds the prompt builder, label cleanup, representative-comment selection and the provider-agnostic generate_cluster_topic_labels orchestrator. - model_provider.py: fix get_batch_responses to POST /v1/messages/batches (was the non-existent singular .../batch, a 404 bug); add retrieve_batch, poll_batch (exponential backoff, TimeoutError), get_batch_results (JSONL) and extract_text_from_result; add supports_batching; honor OLLAMA_HOST. - Provider selection: LLM_PROVIDER (default anthropic), model from ANTHROPIC_TOPIC_MODEL -> ANTHROPIC_MODEL -> claude-haiku-4-5-20251001. - Rename --use-ollama/use_ollama to --name-topics/name_topics; keep --use-ollama as a deprecated alias that forces LLM_PROVIDER=ollama. - run_delphi.py: require OLLAMA_MODEL/HOST only when LLM_PROVIDER=ollama. - Naming failures never crash the pipeline: per-request failures fall back to a generic "Topic N" label; wholesale failure falls back to conventional keyword labels. - Tests (tests/topic_naming/, requests mocked): batch construction, polling until ended, out-of-order result mapping, partial failures, ollama path, fallback. 26 passing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s * delphi(poller): normal worker class processes all job sizes The >5000-comment "large" job routing sent big jobs to a dedicated large worker ASG that is now scaled to zero, so those jobs would never be picked up. Extract the routing decision into should_process_job() and make the normal/default (and small/dev) class process ALL sizes. INSTANCE_SIZE=large remains an opt-in large-only class for anyone re-enabling that ASG. get_job_actual_size is kept for logging/visibility. Adds a unit test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s * deploy: make Ollama service URL optional in after_install.sh The delphi deploy hard-failed (exit 1) when the /polis/ollama-service-url secret was missing. Fetch it with `|| true`, append OLLAMA_HOST only when non-empty, and log-and-continue otherwise so the deploy succeeds with the Ollama infra gone. Topic naming defaults to the Anthropic Batch API. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s * cdk: gate the Ollama GPU stack behind CDK_ENABLE_OLLAMA (default off) Deploying with the flag unset removes the GPU stack; set CDK_ENABLE_OLLAMA=true to recreate it (pair with LLM_PROVIDER=ollama for a self-hosted LLM). Gated: - asgOllama + GPU target-tracking scaling policy (autoscaling.ts) - ollamaLaunchTemplate + its EFS-mount/GPU user data (launchTemplates.ts) - EFS OllamaModelFileSystem + mount targets + efs/ollama SG ingress rules - OllamaNlb + target group + listener - /polis/ollama-service-url secret - OllamaNlbDnsName / OllamaServiceSecretArn / EfsFileSystemId outputs Helper function signatures made to tolerate the absent objects (optional params) instead of duplicating code paths. cwAgentConfigAsset is NOT gated: since #2677 it is shared by every tier's user data, not just Ollama. The empty ollama/efs security groups are left in place (harmless, no rules when off). Verified with `npx cdk synth` (bundling-only DB-backup Lambda stubbed locally to run offline; the Ollama gating itself is unaffected): - flag unset: 0 AWS::EFS::*, 0 NetworkLoadBalancer, 0 g4dn, 4 ASGs, no ollama secret/outputs; CW agent retained. - CDK_ENABLE_OLLAMA=true: 1 EFS + 2 mount targets, 1 g4dn, 1 network LB, ollama secret, 5 ASGs, 3 ollama outputs. tsc --noEmit clean both ways. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s * docs: document LLM_PROVIDER / ANTHROPIC_TOPIC_MODEL and Ollama re-enable - delphi/README.md: new "Topic-cluster naming (LLM provider)" section with the env var table, the Anthropic-batch default and failure behavior, and how to re-enable the self-hosted Ollama GPU stack (CDK_ENABLE_OLLAMA=true + LLM_PROVIDER=ollama). - delphi/example.env: LLM_PROVIDER defaults to anthropic; add ANTHROPIC_TOPIC_MODEL and TOPIC_BATCH_MAX_WAIT_SECONDS; note Ollama is opt-in. - example.env: add ANTHROPIC_TOPIC_MODEL, LLM_PROVIDER, CDK_ENABLE_OLLAMA (no secret values). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…its JVM heap (#2696) * cost: right-size the math worker (r8g.4xlarge -> r8g.2xlarge) and fix its JVM heap setting Why - 14 days of CloudWatch on the r8g.4xlarge (128 GB / 16 vCPU): host memory avg 10.6 GB, peak 16.3 GB; CPU avg 6%, peak 24% (~4 busy cores). ~$700/mo for a box that is 85% empty. - The heap flag in math/deps.edn was a top-level :jvm-opts key, which tools.deps ignores (jvm-opts only apply inside an alias). The JVM has been running at its default heap (1/4 of RAM) the whole time, so the old -Xmx4g never did anything. What - cdk/ec2.ts: math worker r8g.4xlarge -> r8g.2xlarge (8 vCPU / 64 GB). ~$350/mo saved. - cdk/autoscaling.ts: math ASG max 1 (every worker polls every conversation; a second instance duplicates work). Delphi large ASG min/desired 0 to match what has been live since 2026-07-31, so a cdk deploy cannot resurrect a ~$1,040/mo idle box. - math/deps.edn: -Xmx24g inside the :run alias, sized for the 64 GB box; drop the dead top-level key. Rollback: revert cdk/ec2.ts (and the heap line), instance refresh. Minutes. Validation after deploy: CWAgent mem_used_percent and EC2 CPU on the new instance for a week; the 4-hourly restart replay time; update latency on the largest active conversation. Next step if peaks stay under 12 GB: r8g.xlarge with -Xmx12g. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s * cdk: correct the Delphi-large comment — the >5000-comment routing gate still exists --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
colinmegill
marked this pull request as ready for review
September 8, 2026 01:52
Delphi Coverage Report
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PROD DEPLOY 09/08/2026 — edge → stable
Promotes
edgetostable. Previous stable tip:adce54b9a(PROD DEPLOY 07/05/2026). 65 commits.What this carries
Cost-reduction wave 1 (see the merged PRs for detail):
-Xmx24gin the:runalias); math ASG max 1; Delphi-large ASG pinned at 0 in CDK to match what has been live since 2026-07-31.CDK_ENABLE_OLLAMA); topic-cluster naming via the Anthropic Batch API (Haiku 4.5,LLM_PROVIDER=anthropic); normal Delphi workers now process all job sizes; deploy script no longer requires the Ollama secret.Julien's Clojure→Python math stack (#2697, python-math #18–#52) plus fixes for the four must-fix items from independent review (vote-loss on park/unpark, retry overwriting a newer revote, certification cache keys, cache-cap wiring). The prod math engine is unchanged — still Clojure. The Python poller is defined in compose but not started by
after_install.sh. Cutover PRs #2687–#2689 stay held.Undeployed since July: #2609 (subgroup-k clamp) and #2611 (routing restore) — expect math-output changes from those; #2677 (CloudWatch agent on every instance) reaches the launch templates.
Accepted behavior changes (Colin, 2026-09-07): Delphi reports no longer honor the obsolete participant-ban filter; full PCA at all conversation sizes in the Delphi report path.
Deploy steps after merge (Claude executing, Colin/Tim approving the
productionenvironment gate)cdk deploy CdkStack— dry-run diff reviewed: removes Ollama ASG/NLB/EFS mount targets/secret, updates the 4 launch templates (instance type + CW agent), ASG sizes. Nothing else.deploy-alpha-aws.yml(workflow_dispatch onstable) — builds server + math images, CodeDeploy to web/math/delphi. Requires production-environment approval.LLM_PROVIDER=anthropicadded to thepolis-web-app-env-varssecret.describe-instances.Rollback
Revert PR against
stable(git revert -m 1 <merge-sha>), redeploy; CDK:CDK_ENABLE_OLLAMA=true+ revert instance type; see the "revert the revert" note below before the next deploy.stablefirst.🤖 Generated with Claude Code
https://claude.ai/code/session_018NVGBuYCk4EZmiz4csUv9s